From 8477dd9e4ec413baf0e7ea5b59bc1c3a772ceebb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:46:28 -0700 Subject: [PATCH] plugins: per-source annotation policy overrides Add per-source override for which tool invocations require approval, across openapi / graphql / google-discovery / mcp. Each plugin owns its own policy shape (HTTP methods for openapi/google-discovery, operation kinds for graphql, requireApprovalForAll switch for mcp) stored in a separate annotation_policy column so refresh paths don't touch it. Semantics: undefined on update leaves as-is, null clears, concrete value pins. When null, plugin's resolveAnnotations falls back to hardcoded defaults at read time so future default changes propagate. Shared UX: ApprovalPolicyToggles (chip) and ApprovalPolicySwitch (on/off) in @executor/react/plugins/approval-policy-field, wired into each plugin's Add/Edit panels with a Reset-to-defaults affordance. First interaction materializes the current defaults as an explicit override so toggling one option doesn't accidentally clear the rest. Coverage: 34 new tests across pure helpers, storage round-trips, and full integration via executor.{plugin}.addSource/updateSource. --- bun.lock | 1 + .../plugins/google-discovery/package.json | 1 + .../plugins/google-discovery/src/api/group.ts | 19 + .../google-discovery/src/api/handlers.test.ts | 1 + .../google-discovery/src/api/handlers.ts | 11 + .../src/react/AddGoogleDiscoverySource.tsx | 44 ++- .../src/react/EditGoogleDiscoverySource.tsx | 165 +++++++-- .../google-discovery/src/react/atoms.ts | 4 + .../src/sdk/binding-store.test.ts | 185 ++++++++++ .../google-discovery/src/sdk/binding-store.ts | 68 ++++ .../plugins/google-discovery/src/sdk/index.ts | 2 + .../google-discovery/src/sdk/invoke.test.ts | 52 +++ .../google-discovery/src/sdk/invoke.ts | 9 +- .../google-discovery/src/sdk/plugin.ts | 40 ++- .../google-discovery/src/sdk/stored-source.ts | 3 +- .../plugins/google-discovery/src/sdk/types.ts | 13 + packages/plugins/graphql/src/api/group.ts | 6 +- packages/plugins/graphql/src/api/handlers.ts | 2 + .../graphql/src/react/AddGraphqlSource.tsx | 25 ++ .../graphql/src/react/EditGraphqlSource.tsx | 30 ++ .../plugins/graphql/src/sdk/plugin.test.ts | 128 +++++++ packages/plugins/graphql/src/sdk/plugin.ts | 147 +++++--- packages/plugins/graphql/src/sdk/store.ts | 58 ++- packages/plugins/graphql/src/sdk/types.ts | 12 + packages/plugins/mcp/src/api/group.ts | 5 + packages/plugins/mcp/src/api/handlers.ts | 5 + .../plugins/mcp/src/react/AddMcpSource.tsx | 42 ++- .../plugins/mcp/src/react/EditMcpSource.tsx | 83 ++++- packages/plugins/mcp/src/sdk/binding-store.ts | 105 +++++- packages/plugins/mcp/src/sdk/plugin.test.ts | 265 ++++++++++++++ packages/plugins/mcp/src/sdk/plugin.ts | 114 ++++-- packages/plugins/mcp/src/sdk/stored-source.ts | 3 +- packages/plugins/mcp/src/sdk/types.ts | 13 + packages/plugins/openapi/src/api/group.ts | 5 +- packages/plugins/openapi/src/api/handlers.ts | 2 + .../openapi/src/react/AddOpenApiSource.tsx | 19 + .../openapi/src/react/EditOpenApiSource.tsx | 25 ++ packages/plugins/openapi/src/sdk/invoke.ts | 4 +- .../src/sdk/plugin.annotation-policy.test.ts | 319 +++++++++++++++++ packages/plugins/openapi/src/sdk/plugin.ts | 26 +- packages/plugins/openapi/src/sdk/store.ts | 36 ++ .../src/plugins/approval-policy-field.tsx | 330 ++++++++++++++++++ 42 files changed, 2287 insertions(+), 140 deletions(-) create mode 100644 packages/plugins/google-discovery/src/sdk/binding-store.test.ts create mode 100644 packages/plugins/google-discovery/src/sdk/invoke.test.ts create mode 100644 packages/plugins/openapi/src/sdk/plugin.annotation-policy.test.ts create mode 100644 packages/react/src/plugins/approval-policy-field.tsx diff --git a/bun.lock b/bun.lock index 8371d8f52..c70e65ae7 100644 --- a/bun.lock +++ b/bun.lock @@ -534,6 +534,7 @@ "@effect-atom/atom-react": "^0.5.0", "@effect/vitest": "catalog:", "@executor/react": "workspace:*", + "@executor/storage-core": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", diff --git a/packages/plugins/google-discovery/package.json b/packages/plugins/google-discovery/package.json index 8a7858b3a..d726d8e97 100644 --- a/packages/plugins/google-discovery/package.json +++ b/packages/plugins/google-discovery/package.json @@ -57,6 +57,7 @@ "@effect-atom/atom-react": "^0.5.0", "@effect/vitest": "catalog:", "@executor/react": "workspace:*", + "@executor/storage-core": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", diff --git a/packages/plugins/google-discovery/src/api/group.ts b/packages/plugins/google-discovery/src/api/group.ts index 8cbcec70b..7e0cd1953 100644 --- a/packages/plugins/google-discovery/src/api/group.ts +++ b/packages/plugins/google-discovery/src/api/group.ts @@ -9,6 +9,7 @@ import { GoogleDiscoverySourceError, } from "../sdk/errors"; import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; +import { GoogleDiscoveryAnnotationPolicy } from "../sdk/types"; export { HttpApiSchema }; @@ -58,6 +59,7 @@ const AddSourcePayload = Schema.Struct({ discoveryUrl: Schema.String, namespace: Schema.optional(Schema.String), auth: AuthPayload, + annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy), }); const AddSourceResponse = Schema.Struct({ @@ -65,6 +67,16 @@ const AddSourceResponse = Schema.Struct({ namespace: Schema.String, }); +const UpdateSourcePayload = Schema.Struct({ + name: Schema.optional(Schema.String), + // `null` clears a previously-set override; `undefined` leaves as-is. + annotationPolicy: Schema.optional(Schema.NullOr(GoogleDiscoveryAnnotationPolicy)), +}); + +const UpdateSourceResponse = Schema.Struct({ + updated: Schema.Boolean, +}); + const StartOAuthPayload = Schema.Struct({ name: Schema.String, discoveryUrl: Schema.String, @@ -159,6 +171,13 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)), ) + .add( + HttpApiEndpoint.patch( + "updateSource", + )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` + .setPayload(UpdateSourcePayload) + .addSuccess(UpdateSourceResponse), + ) // Errors declared once at the group level — every endpoint inherits. // `InternalError` is the shared opaque 500 translated at the HTTP edge // by `withCapture`. The others are 4xx domain errors carrying their diff --git a/packages/plugins/google-discovery/src/api/handlers.test.ts b/packages/plugins/google-discovery/src/api/handlers.test.ts index 221e8e241..745bf5ede 100644 --- a/packages/plugins/google-discovery/src/api/handlers.test.ts +++ b/packages/plugins/google-discovery/src/api/handlers.test.ts @@ -26,6 +26,7 @@ const failingExtension: GoogleDiscoveryPluginExtension = { startOAuth: () => unused, completeOAuth: () => Effect.die(new Error("Not implemented")), getSource: (_namespace: string, _scope: string) => Effect.succeed(null), + updateSource: () => unused, }; const Api = addGroup(GoogleDiscoveryGroup); diff --git a/packages/plugins/google-discovery/src/api/handlers.ts b/packages/plugins/google-discovery/src/api/handlers.ts index f8f6d998f..7757f544c 100644 --- a/packages/plugins/google-discovery/src/api/handlers.ts +++ b/packages/plugins/google-discovery/src/api/handlers.ts @@ -8,6 +8,7 @@ import type { GoogleDiscoveryAddSourceInput, GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryPluginExtension, + GoogleDiscoveryUpdateSourceInput, } from "../sdk/plugin"; import { GoogleDiscoveryOAuthError } from "../sdk/errors"; import { GoogleDiscoveryGroup } from "./group"; @@ -103,6 +104,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( return yield* ext.getSource(path.namespace, path.scopeId); })), ) + .handle("updateSource", ({ path, payload }) => + capture(Effect.gen(function* () { + const ext = yield* GoogleDiscoveryExtensionService; + yield* ext.updateSource(path.namespace, { + name: payload.name, + annotationPolicy: payload.annotationPolicy, + } satisfies GoogleDiscoveryUpdateSourceInput); + return { updated: true }; + })), + ) .handle("oauthCallback", ({ urlParams }) => capture(Effect.gen(function* () { const ext = yield* GoogleDiscoveryExtensionService; diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index a878591a5..46a31e0cc 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -45,6 +45,10 @@ import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; +import { + ApprovalPolicyToggles, + HTTP_METHOD_TOKENS, +} from "@executor/react/plugins/approval-policy-field"; import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth } from "./atoms"; type GoogleAuthKind = "none" | "oauth2"; @@ -412,6 +416,9 @@ export default function AddGoogleDiscoverySource(props: { const [adding, setAdding] = useState(false); const [error, setError] = useState(null); const [showScopes, setShowScopes] = useState(false); + const [annotationPolicy, setAnnotationPolicy] = useState( + undefined, + ); const scopeId = useScope(); const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" }); @@ -576,6 +583,21 @@ export default function AddGoogleDiscoverySource(props: { authKind === "oauth2" ? (oauthAuth ?? { kind: "none" as const }) : { kind: "none" as const }, + ...(annotationPolicy !== undefined + ? { + annotationPolicy: { + requireApprovalFor: annotationPolicy as readonly ( + | "get" + | "put" + | "post" + | "delete" + | "patch" + | "head" + | "options" + )[], + }, + } + : {}), }, reactivityKeys: [...sourceWriteKeys], }); @@ -586,7 +608,18 @@ export default function AddGoogleDiscoverySource(props: { } finally { placeholder.done(); } - }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId, beginAdd]); + }, [ + probe, + doAdd, + identity, + discoveryUrl, + authKind, + oauthAuth, + props, + scopeId, + beginAdd, + annotationPolicy, + ]); const addDisabled = !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); @@ -797,6 +830,15 @@ export default function AddGoogleDiscoverySource(props: { )} + {probe && ( + + )} + {error && (
{error} diff --git a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx index fbb87f8bf..5e5de82eb 100644 --- a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx @@ -1,40 +1,86 @@ -import { useAtomValue, Result } from "@effect-atom/atom-react"; +import { useState } from "react"; +import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; import { useScope } from "@executor/react/api/scope-context"; +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; import { Badge } from "@executor/react/components/badge"; import { Button } from "@executor/react/components/button"; +import { + ApprovalPolicyToggles, + HTTP_METHOD_TOKENS, +} from "@executor/react/plugins/approval-policy-field"; +import type { GoogleDiscoveryStoredSourceSchemaType } from "../sdk/stored-source"; -import { googleDiscoverySourceAtom } from "./atoms"; +import { googleDiscoverySourceAtom, updateGoogleDiscoverySource } from "./atoms"; -export default function EditGoogleDiscoverySource({ - sourceId, - onSave, -}: { - readonly sourceId: string; - readonly onSave: () => void; +function EditForm(props: { + sourceId: string; + initial: GoogleDiscoveryStoredSourceSchemaType; + onSave: () => void; }) { const scopeId = useScope(); - const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, sourceId)); + const doUpdate = useAtomSet(updateGoogleDiscoverySource, { mode: "promise" }); + + const config = props.initial.config; + const authKind = config.auth.kind; + + const [annotationPolicy, setAnnotationPolicy] = useState( + props.initial.annotationPolicy?.requireApprovalFor + ? [...props.initial.annotationPolicy.requireApprovalFor] + : undefined, + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [dirty, setDirty] = useState(false); - const source = Result.isSuccess(sourceResult) ? sourceResult.value : null; - const config = source?.config; - const authKind = config?.auth.kind ?? "none"; + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await doUpdate({ + path: { scopeId, namespace: props.sourceId }, + payload: { + annotationPolicy: + annotationPolicy === undefined + ? null + : { + requireApprovalFor: annotationPolicy as readonly ( + | "get" + | "put" + | "post" + | "delete" + | "patch" + | "head" + | "options" + )[], + }, + }, + reactivityKeys: sourceWriteKeys, + }); + setDirty(false); + props.onSave(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update source"); + } finally { + setSaving(false); + } + }; return (

Edit Google Discovery Source

- View configuration for this Google API source. To change authentication, remove and re-add - the source with updated OAuth credentials. + Adjust the approval policy for this Google API source. To change authentication, remove + and re-add the source with updated OAuth credentials.

- {source?.name ?? sourceId} + {props.initial.name}

- {config?.discoveryUrl && ( + {config.discoveryUrl && (

{config.discoveryUrl}

@@ -45,37 +91,80 @@ export default function EditGoogleDiscoverySource({
- {config && ( -
-
-
-

- Service -

-

{config.service}

-
-
-

- Version -

-

{config.version}

-
-
- +
+

- Authentication + Service

-

- {authKind === "oauth2" ? "OAuth 2.0" : authKind} +

{config.service}

+
+
+

+ Version

+

{config.version}

+ +
+

+ Authentication +

+

+ {authKind === "oauth2" ? "OAuth 2.0" : authKind} +

+
+
+ + { + setAnnotationPolicy(next); + setDirty(true); + }} + description="Choose which HTTP methods require approval before a tool call from this source runs. Defaults: write methods (POST / PUT / PATCH / DELETE) require approval." + /> + + {error && ( +
+

{error}

+
)} -
- +
+ +
); } + +export default function EditGoogleDiscoverySource({ + sourceId, + onSave, +}: { + readonly sourceId: string; + readonly onSave: () => void; +}) { + const scopeId = useScope(); + const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, sourceId)); + + if (!Result.isSuccess(sourceResult) || !sourceResult.value) { + return ( +
+
+

Edit Google Discovery Source

+

Loading configuration…

+
+
+ ); + } + + return ; +} diff --git a/packages/plugins/google-discovery/src/react/atoms.ts b/packages/plugins/google-discovery/src/react/atoms.ts index 2c8afabc9..21bd907c3 100644 --- a/packages/plugins/google-discovery/src/react/atoms.ts +++ b/packages/plugins/google-discovery/src/react/atoms.ts @@ -25,3 +25,7 @@ export const completeGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation( "googleDiscovery", "completeOAuth", ); +export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation( + "googleDiscovery", + "updateSource", +); diff --git a/packages/plugins/google-discovery/src/sdk/binding-store.test.ts b/packages/plugins/google-discovery/src/sdk/binding-store.test.ts new file mode 100644 index 000000000..b72c32f3a --- /dev/null +++ b/packages/plugins/google-discovery/src/sdk/binding-store.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { typedAdapter } from "@executor/storage-core"; +import { makeMemoryAdapter } from "@executor/storage-core/testing/memory"; +import { + makeInMemoryBlobStore, + pluginBlobStore, + Scope, + ScopeId, + type StorageDeps, +} from "@executor/sdk"; + +import { + googleDiscoverySchema, + makeGoogleDiscoveryStore, + type GoogleDiscoverySchema, + type GoogleDiscoveryStoredSource, +} from "./binding-store"; +import { + GoogleDiscoveryAnnotationPolicy, + GoogleDiscoveryStoredSourceData, +} from "./types"; + +// --------------------------------------------------------------------------- +// Test harness — build a GoogleDiscoveryStore backed by the memory adapter. +// Bypasses createExecutor so these tests are narrow-scope (and don't pull +// in the full plugin wiring). +// --------------------------------------------------------------------------- + +const TEST_SCOPE = "test-scope"; + +const makeStore = () => { + const adapter = makeMemoryAdapter({ schema: googleDiscoverySchema }); + const scope = new Scope({ + id: ScopeId.make(TEST_SCOPE), + name: "test", + createdAt: new Date(), + }); + const deps: StorageDeps = { + scopes: [scope], + adapter: typedAdapter(adapter), + blobs: pluginBlobStore( + makeInMemoryBlobStore(), + [scope.id as string], + "google-discovery-test", + ), + }; + return makeGoogleDiscoveryStore(deps); +}; + +const makeSourceData = (name: string = "Google Drive") => + new GoogleDiscoveryStoredSourceData({ + name, + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", + service: "drive", + version: "v3", + rootUrl: "https://www.googleapis.com/", + servicePath: "drive/v3/", + auth: { kind: "none" as const }, + }); + +const sourceFixture = ( + annotationPolicy?: GoogleDiscoveryAnnotationPolicy, +): GoogleDiscoveryStoredSource => ({ + namespace: "drive", + scope: TEST_SCOPE, + name: "Google Drive", + config: makeSourceData(), + ...(annotationPolicy !== undefined ? { annotationPolicy } : {}), +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("GoogleDiscoveryStore annotation policy storage", () => { + it.effect("round-trips a concrete annotation policy via putSource/getSource", () => + Effect.gen(function* () { + const store = makeStore(); + const policy = new GoogleDiscoveryAnnotationPolicy({ + requireApprovalFor: ["get", "post"], + }); + + yield* store.putSource(sourceFixture(policy)); + + const fetched = yield* store.getSource("drive", TEST_SCOPE); + expect(fetched).not.toBeNull(); + expect(fetched!.annotationPolicy).toBeDefined(); + expect(fetched!.annotationPolicy!.requireApprovalFor).toEqual([ + "get", + "post", + ]); + }), + ); + + it.effect( + "preserves an existing annotation policy when putSource is called without one (refresh path)", + () => + Effect.gen(function* () { + const store = makeStore(); + const policy = new GoogleDiscoveryAnnotationPolicy({ + requireApprovalFor: ["delete"], + }); + + // 1. Seed with a policy. + yield* store.putSource(sourceFixture(policy)); + + // 2. Second putSource (simulating refreshSource) supplies NO policy. + yield* store.putSource(sourceFixture(undefined)); + + // 3. The original policy must still be present. + const fetched = yield* store.getSource("drive", TEST_SCOPE); + expect(fetched).not.toBeNull(); + expect(fetched!.annotationPolicy).toBeDefined(); + expect(fetched!.annotationPolicy!.requireApprovalFor).toEqual([ + "delete", + ]); + }), + ); + + it.effect("getSource returns undefined annotationPolicy when none is stored", () => + Effect.gen(function* () { + const store = makeStore(); + yield* store.putSource(sourceFixture(undefined)); + + const fetched = yield* store.getSource("drive", TEST_SCOPE); + expect(fetched).not.toBeNull(); + expect(fetched!.annotationPolicy).toBeUndefined(); + }), + ); + + it.effect("updateSourceMeta with a concrete value persists the override", () => + Effect.gen(function* () { + const store = makeStore(); + yield* store.putSource(sourceFixture(undefined)); + + yield* store.updateSourceMeta("drive", { + annotationPolicy: new GoogleDiscoveryAnnotationPolicy({ + requireApprovalFor: ["patch"], + }), + }); + + const fetched = yield* store.getSource("drive", TEST_SCOPE); + expect(fetched!.annotationPolicy!.requireApprovalFor).toEqual(["patch"]); + }), + ); + + it.effect("updateSourceMeta with null clears the override", () => + Effect.gen(function* () { + const store = makeStore(); + const policy = new GoogleDiscoveryAnnotationPolicy({ + requireApprovalFor: ["post"], + }); + yield* store.putSource(sourceFixture(policy)); + + // Confirm the fixture landed. + const before = yield* store.getSource("drive", TEST_SCOPE); + expect(before!.annotationPolicy).toBeDefined(); + + yield* store.updateSourceMeta("drive", { annotationPolicy: null }); + + const after = yield* store.getSource("drive", TEST_SCOPE); + expect(after).not.toBeNull(); + expect(after!.annotationPolicy).toBeUndefined(); + }), + ); + + it.effect("updateSourceMeta with undefined leaves the override unchanged", () => + Effect.gen(function* () { + const store = makeStore(); + const policy = new GoogleDiscoveryAnnotationPolicy({ + requireApprovalFor: ["put"], + }); + yield* store.putSource(sourceFixture(policy)); + + // Only update the name. + yield* store.updateSourceMeta("drive", { name: "Renamed Drive" }); + + const fetched = yield* store.getSource("drive", TEST_SCOPE); + expect(fetched!.name).toBe("Renamed Drive"); + expect(fetched!.annotationPolicy!.requireApprovalFor).toEqual(["put"]); + }), + ); +}); diff --git a/packages/plugins/google-discovery/src/sdk/binding-store.ts b/packages/plugins/google-discovery/src/sdk/binding-store.ts index c8906c930..e917a9493 100644 --- a/packages/plugins/google-discovery/src/sdk/binding-store.ts +++ b/packages/plugins/google-discovery/src/sdk/binding-store.ts @@ -21,6 +21,7 @@ import { } from "@executor/sdk"; import { + GoogleDiscoveryAnnotationPolicy, GoogleDiscoveryMethodBinding, GoogleDiscoveryOAuthSession, GoogleDiscoveryStoredSourceData, @@ -43,6 +44,7 @@ export const googleDiscoverySchema = defineSchema({ scope_id: { type: "string", required: true, index: true }, name: { type: "string", required: true }, config: { type: "json", required: true }, + annotation_policy: { type: "json", required: false }, created_at: { type: "date", required: true }, updated_at: { type: "date", required: true }, }, @@ -80,6 +82,7 @@ export interface GoogleDiscoveryStoredSource { readonly scope: string; readonly name: string; readonly config: GoogleDiscoveryStoredSourceData; + readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy; } // --------------------------------------------------------------------------- @@ -95,6 +98,9 @@ const decodeBinding = Schema.decodeUnknownSync(GoogleDiscoveryMethodBinding); const encodeSession = Schema.encodeSync(GoogleDiscoveryOAuthSession); const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSession); +const encodeAnnotationPolicy = Schema.encodeSync(GoogleDiscoveryAnnotationPolicy); +const decodeAnnotationPolicy = Schema.decodeUnknownSync(GoogleDiscoveryAnnotationPolicy); + const decodeJson = (value: unknown): unknown => { if (value === null || value === undefined) return value; if (typeof value !== "string") return value; @@ -166,6 +172,15 @@ export interface GoogleDiscoveryStore { sourceId: string, scope: string, ) => Effect.Effect; + /** Update mutable source metadata. `annotationPolicy: null` clears the + * override; `undefined` leaves it as-is; a concrete value sets it. */ + readonly updateSourceMeta: ( + sourceId: string, + input: { + readonly name?: string; + readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy | null; + }, + ) => Effect.Effect; readonly putOAuthSession: ( sessionId: string, @@ -267,6 +282,21 @@ export const makeGoogleDiscoveryStore = ( putSource: (source) => Effect.gen(function* () { const now = new Date(); + // Preserve any existing annotationPolicy unless the caller supplies one. + // Only `updateSourceMeta` and explicit inputs should touch it. Pin to + // the target scope so a shadowed row at another scope can't leak its + // policy onto this write. + const existing = yield* db.findOne({ + model: "google_discovery_source", + where: [ + { field: "id", value: source.namespace }, + { field: "scope_id", value: source.scope }, + ], + }); + const existingPolicy = + existing?.annotation_policy !== undefined + ? existing.annotation_policy + : null; yield* db.delete({ model: "google_discovery_source", where: [ @@ -274,6 +304,13 @@ export const makeGoogleDiscoveryStore = ( { field: "scope_id", value: source.scope }, ], }); + const nextPolicy = + source.annotationPolicy !== undefined + ? (encodeAnnotationPolicy(source.annotationPolicy) as unknown as Record< + string, + unknown + >) + : existingPolicy; yield* db.create({ model: "google_discovery_source", data: { @@ -281,6 +318,9 @@ export const makeGoogleDiscoveryStore = ( scope_id: source.scope, name: source.name, config: encodeStoredSourceData(source.config) as unknown as Record, + ...(nextPolicy !== null + ? { annotation_policy: nextPolicy as Record } + : {}), created_at: now, updated_at: now, }, @@ -288,6 +328,28 @@ export const makeGoogleDiscoveryStore = ( }); }), + updateSourceMeta: (sourceId, input) => + Effect.gen(function* () { + const updates: Record = {}; + if (input.name !== undefined) updates.name = input.name; + if (input.annotationPolicy !== undefined) { + updates.annotation_policy = + input.annotationPolicy === null + ? null + : (encodeAnnotationPolicy(input.annotationPolicy) as unknown as Record< + string, + unknown + >); + } + if (Object.keys(updates).length === 0) return; + updates.updated_at = new Date(); + yield* db.update({ + model: "google_discovery_source", + where: [{ field: "id", value: sourceId }], + update: updates, + }); + }), + removeSource: (sourceId, scope) => db .delete({ @@ -309,11 +371,17 @@ export const makeGoogleDiscoveryStore = ( ], }); if (!row) return null; + const rawPolicy = row.annotation_policy; + const annotationPolicy = + rawPolicy === undefined || rawPolicy === null + ? undefined + : decodeAnnotationPolicy(decodeJson(rawPolicy)); return { namespace: row.id as string, scope: row.scope_id as string, name: row.name as string, config: decodeStoredSourceData(decodeJson(row.config)), + ...(annotationPolicy !== undefined ? { annotationPolicy } : {}), }; }), diff --git a/packages/plugins/google-discovery/src/sdk/index.ts b/packages/plugins/google-discovery/src/sdk/index.ts index a3e39075f..bf39eb521 100644 --- a/packages/plugins/google-discovery/src/sdk/index.ts +++ b/packages/plugins/google-discovery/src/sdk/index.ts @@ -7,6 +7,7 @@ export type { GoogleDiscoveryOAuthStartResponse, GoogleDiscoveryPluginExtension, GoogleDiscoveryProbeResult, + GoogleDiscoveryUpdateSourceInput, } from "./plugin"; export { extractGoogleDiscoveryManifest } from "./document"; export { @@ -26,6 +27,7 @@ export { exchangeAuthorizationCode, } from "./oauth"; export { + GoogleDiscoveryAnnotationPolicy, GoogleDiscoveryAuth, GoogleDiscoveryHttpMethod, GoogleDiscoveryInvocationResult, diff --git a/packages/plugins/google-discovery/src/sdk/invoke.test.ts b/packages/plugins/google-discovery/src/sdk/invoke.test.ts new file mode 100644 index 000000000..fb9693c11 --- /dev/null +++ b/packages/plugins/google-discovery/src/sdk/invoke.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { annotationsForOperation } from "./invoke"; + +// --------------------------------------------------------------------------- +// Pure tests for the Google Discovery annotation helper. Exercises the +// default (POST / PUT / PATCH / DELETE) policy plus per-source overrides +// supplied via `GoogleDiscoveryAnnotationPolicy.requireApprovalFor`. +// --------------------------------------------------------------------------- + +describe("annotationsForOperation", () => { + it("applies the default policy when no override is supplied", () => { + const post = annotationsForOperation("post", "/foo", undefined); + expect(post.requiresApproval).toBe(true); + expect(post.approvalDescription).toBe("POST /foo"); + + const get = annotationsForOperation("get", "/foo", undefined); + expect(get.requiresApproval).toBeUndefined(); + expect(get.approvalDescription).toBeUndefined(); + }); + + it("honors overrides that include GET", () => { + const result = annotationsForOperation("get", "/foo", { + requireApprovalFor: ["get"], + }); + expect(result.requiresApproval).toBe(true); + expect(result.approvalDescription).toBe("GET /foo"); + }); + + it("honors overrides that exclude POST", () => { + const result = annotationsForOperation("post", "/foo", { + requireApprovalFor: ["get"], + }); + expect(result.requiresApproval).toBeUndefined(); + expect(result.approvalDescription).toBeUndefined(); + }); + + it("treats an empty override as approval-for-nothing", () => { + const result = annotationsForOperation("delete", "/foo", { + requireApprovalFor: [], + }); + expect(result.requiresApproval).toBeUndefined(); + expect(result.approvalDescription).toBeUndefined(); + }); + + it("treats a null policy the same as undefined (fall back to defaults)", () => { + const post = annotationsForOperation("post", "/foo", null); + expect(post.requiresApproval).toBe(true); + const get = annotationsForOperation("get", "/foo", null); + expect(get.requiresApproval).toBeUndefined(); + }); +}); diff --git a/packages/plugins/google-discovery/src/sdk/invoke.ts b/packages/plugins/google-discovery/src/sdk/invoke.ts index 1b133731d..67c7ab9ae 100644 --- a/packages/plugins/google-discovery/src/sdk/invoke.ts +++ b/packages/plugins/google-discovery/src/sdk/invoke.ts @@ -22,13 +22,18 @@ import { type GoogleDiscoveryParameter, } from "./types"; -const SAFE_METHODS = new Set(["get", "head", "options"]); +const DEFAULT_REQUIRE_APPROVAL = new Set(["post", "put", "patch", "delete"]); export const annotationsForOperation = ( method: string, pathTemplate: string, + policy?: { readonly requireApprovalFor?: readonly string[] } | null, ): { requiresApproval?: boolean; approvalDescription?: string } => { - if (SAFE_METHODS.has(method.toLowerCase())) return {}; + const m = method.toLowerCase(); + const requireSet = policy?.requireApprovalFor + ? new Set(policy.requireApprovalFor.map((v) => v.toLowerCase())) + : DEFAULT_REQUIRE_APPROVAL; + if (!requireSet.has(m)) return {}; return { requiresApproval: true, approvalDescription: `${method.toUpperCase()} ${pathTemplate}`, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index d538cc4e6..ba1479123 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -32,6 +32,7 @@ import { exchangeAuthorizationCode, } from "./oauth"; import type { + GoogleDiscoveryAnnotationPolicy, GoogleDiscoveryAuth, GoogleDiscoveryManifest, GoogleDiscoveryManifestMethod, @@ -67,6 +68,16 @@ export interface GoogleDiscoveryAddSourceInput { readonly discoveryUrl: string; readonly namespace?: string; readonly auth: GoogleDiscoveryAuth; + /** Per-source override for the default HTTP-method-based annotation + * policy. Omit to use the default (POST / PUT / PATCH / DELETE require + * approval). */ + readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy; +} + +export interface GoogleDiscoveryUpdateSourceInput { + readonly name?: string; + /** `null` clears a previously-set override; `undefined` leaves as-is. */ + readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy | null; } export interface GoogleDiscoveryOAuthStartInput { @@ -155,6 +166,10 @@ export interface GoogleDiscoveryPluginExtension { namespace: string, scope: string, ) => Effect.Effect; + readonly updateSource: ( + namespace: string, + input: GoogleDiscoveryUpdateSourceInput, + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -231,6 +246,7 @@ const registerManifest = ( scope: string, manifest: GoogleDiscoveryManifest, sourceData: GoogleDiscoveryStoredSourceData, + annotationPolicy?: GoogleDiscoveryAnnotationPolicy, ) => Effect.gen(function* () { // 1. Clear any previous manifest for this namespace at this scope. @@ -286,6 +302,7 @@ const registerManifest = ( scope, name: sourceData.name, config: sourceData, + ...(annotationPolicy !== undefined ? { annotationPolicy } : {}), }); return manifest.methods.length; @@ -381,6 +398,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ input.scope, manifest, sourceData, + input.annotationPolicy, ); return { toolCount, namespace }; }), @@ -514,6 +532,12 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ }), getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope), + + updateSource: (namespace, input) => + ctx.storage.updateSourceMeta(namespace, { + name: input.name?.trim() || undefined, + annotationPolicy: input.annotationPolicy, + }), } satisfies GoogleDiscoveryPluginExtension), invokeTool: ({ ctx, toolRow, args }) => @@ -534,22 +558,28 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ // toolRows for a single (plugin_id, source_id) group can still // straddle multiple scopes when the source is shadowed (e.g. an // org-level source plus a per-user override that re-registers - // the same tool ids). Run one getBindingsForSource per distinct - // scope so each lookup pins {source_id, scope_id} and we don't - // fall through to the wrong scope's bindings. + // the same tool ids). Run one getBindingsForSource + getSource + // per distinct scope so each lookup pins {source_id, scope_id} + // and we don't fall through to the wrong scope's bindings or + // annotation policy. const typedCtx = ctx as PluginCtx; const scopes = new Set(); for (const row of toolRows) scopes.add(row.scope_id as string); const byScope = new Map>(); + const policyByScope = new Map(); for (const scope of scopes) { const bindings = yield* typedCtx.storage.getBindingsForSource(sourceId, scope); byScope.set(scope, bindings); + const source = yield* typedCtx.storage.getSource(sourceId, scope); + policyByScope.set(scope, source?.annotationPolicy); } const out: Record = {}; for (const row of toolRows) { - const binding = byScope.get(row.scope_id as string)?.get(row.id); + const scope = row.scope_id as string; + const binding = byScope.get(scope)?.get(row.id); if (binding) { - out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate); + const policy = policyByScope.get(scope); + out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate, policy); } } return out; diff --git a/packages/plugins/google-discovery/src/sdk/stored-source.ts b/packages/plugins/google-discovery/src/sdk/stored-source.ts index 6699ec829..d5c2262ed 100644 --- a/packages/plugins/google-discovery/src/sdk/stored-source.ts +++ b/packages/plugins/google-discovery/src/sdk/stored-source.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { GoogleDiscoveryStoredSourceData } from "./types"; +import { GoogleDiscoveryAnnotationPolicy, GoogleDiscoveryStoredSourceData } from "./types"; // --------------------------------------------------------------------------- // Stored source — the shape persisted by the binding store and exposed @@ -13,6 +13,7 @@ export class GoogleDiscoveryStoredSourceSchema extends Schema.Class( + "GoogleDiscoveryAnnotationPolicy", +)({ + requireApprovalFor: Schema.optional(Schema.Array(GoogleDiscoveryHttpMethod)), +}) {} + /** Pending OAuth session persisted between startOAuth and completeOAuth */ export const GoogleDiscoveryOAuthSession = Schema.Struct({ discoveryUrl: Schema.String, diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 76c762ee9..3d29c4a1b 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -4,7 +4,7 @@ import { ScopeId } from "@executor/sdk"; import { InternalError } from "@executor/api"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; -import { HeaderValue } from "../sdk/types"; +import { AnnotationPolicy, HeaderValue } from "../sdk/types"; // StoredGraphqlSource shape as an HTTP response schema. Kept local to the // api layer because the sdk-side `StoredGraphqlSource` is a plain interface. @@ -13,6 +13,7 @@ const StoredSourceSchema = Schema.Struct({ name: Schema.String, endpoint: Schema.String, headers: Schema.Record({ key: Schema.String, value: HeaderValue }), + annotationPolicy: Schema.optional(AnnotationPolicy), }); // --------------------------------------------------------------------------- @@ -32,12 +33,15 @@ const AddSourcePayload = Schema.Struct({ introspectionJson: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const UpdateSourcePayload = Schema.Struct({ name: Schema.optional(Schema.String), endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + // `null` clears a previously-set override; `undefined` leaves as-is. + annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)), }); const UpdateSourceResponse = Schema.Struct({ diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 56368b20b..2a55f98e4 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -53,6 +53,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra introspectionJson: payload.introspectionJson, namespace: payload.namespace, headers: payload.headers as Record | undefined, + annotationPolicy: payload.annotationPolicy, }); return { toolCount: result.toolCount, @@ -73,6 +74,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra name: payload.name, endpoint: payload.endpoint, headers: payload.headers as Record | undefined, + annotationPolicy: payload.annotationPolicy, } as GraphqlUpdateSourceInput); return { updated: true }; })), diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 7749650ac..1aea11ade 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -5,6 +5,10 @@ import { useScope } from "@executor/react/api/scope-context"; import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; import { usePendingSources } from "@executor/react/api/optimistic"; import { HeadersList } from "@executor/react/plugins/headers-list"; +import { + ApprovalPolicyToggles, + GRAPHQL_OPERATION_TOKENS, +} from "@executor/react/plugins/approval-policy-field"; import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; import { displayNameFromUrl, @@ -43,6 +47,9 @@ export default function AddGraphqlSource(props: { fallbackName: displayNameFromUrl(endpoint) ?? "", }); const [headers, setHeaders] = useState([initialHeader()]); + const [annotationPolicy, setAnnotationPolicy] = useState( + undefined, + ); const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); @@ -89,6 +96,16 @@ export default function AddGraphqlSource(props: { name: identity.name.trim() || undefined, namespace: slugifyNamespace(identity.namespace) || undefined, ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), + ...(annotationPolicy !== undefined + ? { + annotationPolicy: { + requireApprovalFor: annotationPolicy as readonly ( + | "query" + | "mutation" + )[], + }, + } + : {}), }, reactivityKeys: sourceWriteKeys, }); @@ -136,6 +153,14 @@ export default function AddGraphqlSource(props: { /> + + {/* Error */} {addError && (
diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 02ec52277..891e915c1 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -10,6 +10,10 @@ import { type HeaderState, } from "@executor/react/plugins/secret-header-auth"; import { HeadersList } from "@executor/react/plugins/headers-list"; +import { + ApprovalPolicyToggles, + GRAPHQL_OPERATION_TOKENS, +} from "@executor/react/plugins/approval-policy-field"; import { SourceIdentityFields, useSourceIdentity, @@ -52,6 +56,12 @@ function EditForm(props: { headerValueToState(name, value), ), ); + const [annotationPolicy, setAnnotationPolicy] = useState( + () => { + const stored = props.initial.annotationPolicy?.requireApprovalFor; + return stored ? [...stored] : undefined; + }, + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); @@ -73,6 +83,15 @@ function EditForm(props: { name: identity.name.trim() || undefined, endpoint: endpoint.trim() || undefined, headers: headersFromState(headers), + annotationPolicy: + annotationPolicy === undefined + ? null + : { + requireApprovalFor: annotationPolicy as readonly ( + | "query" + | "mutation" + )[], + }, }, reactivityKeys: sourceWriteKeys, }); @@ -131,6 +150,17 @@ function EditForm(props: { /> + { + setAnnotationPolicy(next); + setDirty(true); + }} + layout="list" + description="Choose which operation kinds require approval before a tool call runs." + /> + {error && (

{error}

diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 89e3071b2..fa280dd5c 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -207,6 +207,134 @@ describe("graphqlPlugin", () => { }), ); + it.effect("annotation policy override: queries also require approval", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [graphqlPlugin()] as const }), + ); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + introspectionJson, + namespace: "policy_queries", + annotationPolicy: { requireApprovalFor: ["query", "mutation"] }, + }); + + const tools = yield* executor.tools.list(); + const queryTool = tools.find( + (t) => t.id === "policy_queries.query.hello", + ); + expect(queryTool).toBeDefined(); + expect(queryTool!.annotations?.requiresApproval).toBe(true); + expect(queryTool!.annotations?.approvalDescription).toBe("query hello"); + }), + ); + + it.effect("annotation policy override: mutations skip approval", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [graphqlPlugin()] as const }), + ); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + introspectionJson, + namespace: "policy_muts_off", + annotationPolicy: { requireApprovalFor: ["query"] }, + }); + + const tools = yield* executor.tools.list(); + const mutationTool = tools.find( + (t) => t.id === "policy_muts_off.mutation.setGreeting", + ); + expect(mutationTool).toBeDefined(); + expect(mutationTool!.annotations?.requiresApproval).toBeFalsy(); + }), + ); + + it.effect("annotation policy override: empty array skips approval for all kinds", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [graphqlPlugin()] as const }), + ); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + introspectionJson, + namespace: "policy_none", + annotationPolicy: { requireApprovalFor: [] }, + }); + + const tools = yield* executor.tools.list(); + const queryTool = tools.find((t) => t.id === "policy_none.query.hello"); + const mutationTool = tools.find( + (t) => t.id === "policy_none.mutation.setGreeting", + ); + expect(queryTool!.annotations?.requiresApproval).toBeFalsy(); + expect(mutationTool!.annotations?.requiresApproval).toBeFalsy(); + }), + ); + + it.effect("updateSource with annotationPolicy: null clears the override", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [graphqlPlugin()] as const }), + ); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + introspectionJson, + namespace: "policy_clear", + annotationPolicy: { requireApprovalFor: ["query"] }, + }); + + let tools = yield* executor.tools.list(); + let mutationTool = tools.find( + (t) => t.id === "policy_clear.mutation.setGreeting", + ); + expect(mutationTool!.annotations?.requiresApproval).toBeFalsy(); + + yield* executor.graphql.updateSource("policy_clear", { + annotationPolicy: null, + }); + + tools = yield* executor.tools.list(); + mutationTool = tools.find( + (t) => t.id === "policy_clear.mutation.setGreeting", + ); + expect(mutationTool!.annotations?.requiresApproval).toBe(true); + }), + ); + + it.effect("updateSource leaves annotationPolicy untouched when key omitted", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [graphqlPlugin()] as const }), + ); + + yield* executor.graphql.addSource({ + endpoint: "http://localhost:4000/graphql", + introspectionJson, + namespace: "policy_keep", + annotationPolicy: { requireApprovalFor: ["query"] }, + }); + + yield* executor.graphql.updateSource("policy_keep", { + endpoint: "http://localhost:5000/graphql", + }); + + const tools = yield* executor.tools.list(); + const mutationTool = tools.find( + (t) => t.id === "policy_keep.mutation.setGreeting", + ); + expect(mutationTool!.annotations?.requiresApproval).toBeFalsy(); + + const source = yield* executor.graphql.getSource("policy_keep"); + expect(source?.endpoint).toBe("http://localhost:5000/graphql"); + expect(source?.annotationPolicy?.requireApprovalFor).toEqual(["query"]); + }), + ); + it.effect("updateSource patches endpoint/headers without re-registering", () => Effect.gen(function* () { const executor = yield* createExecutor( diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index df03f95f6..b693b6d55 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -35,6 +35,7 @@ import { type StoredOperation, } from "./store"; import { + AnnotationPolicy, ExtractedField, OperationBinding, type HeaderValue as HeaderValueValue, @@ -54,9 +55,10 @@ export interface GraphqlSourceConfig { * Executor scope id that owns this source row. Must be one of the * executor's configured scopes. Typical shape: an admin adds the * source at the outermost (organization) scope so it's visible to - * every inner (per-user) scope via fall-through reads. + * every inner (per-user) scope via fall-through reads. When omitted, + * the plugin defaults to the innermost scope (`ctx.scopes[0].id`). */ - readonly scope: string; + readonly scope?: string; /** Display name for the source. Falls back to namespace if not provided. */ readonly name?: string; /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ @@ -65,6 +67,8 @@ export interface GraphqlSourceConfig { readonly namespace?: string; /** Headers applied to every request. Values can reference secrets. */ readonly headers?: Record; + /** Per-source annotation policy override. Omitted = plugin defaults. */ + readonly annotationPolicy?: AnnotationPolicy; } // --------------------------------------------------------------------------- @@ -75,6 +79,8 @@ export interface GraphqlUpdateSourceInput { readonly name?: string; readonly endpoint?: string; readonly headers?: Record; + /** `null` clears a previously-set override; `undefined` leaves as-is. */ + readonly annotationPolicy?: AnnotationPolicy | null; } /** @@ -106,23 +112,37 @@ export interface GraphqlPluginExtension { ) => Effect.Effect; /** Fetch the full stored source by namespace (or null if missing). - * `scope` returns the exact row at that scope. For fall-through - * reads across the executor's scope stack, use `executor.sources.*`. */ - readonly getSource: ( - namespace: string, - scope: string, - ) => Effect.Effect; - - /** Update config (endpoint, headers) for an existing GraphQL source. - * Does NOT re-introspect or re-register tools — just patches the - * stored endpoint/headers used at invoke time. `scope` pins the - * mutation to a single row so shadowed rows at other scopes are - * untouched. */ - readonly updateSource: ( - namespace: string, - scope: string, - input: GraphqlUpdateSourceInput, - ) => Effect.Effect; + * When `scope` is provided it returns the exact row at that scope; + * when omitted the plugin defaults to the innermost scope + * (`ctx.scopes[0].id`). For fall-through reads across the executor's + * scope stack, use `executor.sources.*`. */ + readonly getSource: { + ( + namespace: string, + scope: string, + ): Effect.Effect; + ( + namespace: string, + ): Effect.Effect; + }; + + /** Update config (endpoint, headers, annotation policy) for an + * existing GraphQL source. Does NOT re-introspect or re-register + * tools — just patches the stored values used at invoke time. + * When `scope` is provided it pins the mutation to a single row so + * shadowed rows at other scopes are untouched; when omitted the + * plugin defaults to the innermost scope (`ctx.scopes[0].id`). */ + readonly updateSource: { + ( + namespace: string, + scope: string, + input: GraphqlUpdateSourceInput, + ): Effect.Effect; + ( + namespace: string, + input: GraphqlUpdateSourceInput, + ): Effect.Effect; + }; } // --------------------------------------------------------------------------- @@ -274,14 +294,20 @@ const prepareOperations = ( }); }; -const annotationsFor = (binding: OperationBinding): ToolAnnotations => { - if (binding.kind === "mutation") { - return { - requiresApproval: true, - approvalDescription: `mutation ${binding.fieldName}`, - }; - } - return {}; +const DEFAULT_REQUIRE_APPROVAL_KINDS = new Set(["mutation"]); + +const annotationsFor = ( + binding: OperationBinding, + policy?: AnnotationPolicy | undefined, +): ToolAnnotations => { + const requireSet = policy?.requireApprovalFor + ? new Set(policy.requireApprovalFor) + : DEFAULT_REQUIRE_APPROVAL_KINDS; + if (!requireSet.has(binding.kind)) return {}; + return { + requiresApproval: true, + approvalDescription: `${binding.kind} ${binding.fieldName}`, + }; }; // --------------------------------------------------------------------------- @@ -352,15 +378,20 @@ export const graphqlPlugin = definePlugin( ); const displayName = config.name?.trim() || namespace; + const resolvedScope = + config.scope ?? (ctx.scopes[0]!.id as string); // Persist the source + per-operation bindings first so any // subsequent core-source register collision rolls back both. const storedSource: StoredGraphqlSource = { namespace, - scope: config.scope, + scope: resolvedScope, name: displayName, endpoint: config.endpoint, headers: config.headers ?? {}, + ...(config.annotationPolicy + ? { annotationPolicy: config.annotationPolicy } + : {}), }; const storedOps: StoredOperation[] = prepared.map((p) => ({ @@ -373,7 +404,7 @@ export const graphqlPlugin = definePlugin( yield* ctx.core.sources.register({ id: namespace, - scope: config.scope, + scope: resolvedScope, kind: "graphql", name: displayName, url: config.endpoint, @@ -390,7 +421,7 @@ export const graphqlPlugin = definePlugin( if (Object.keys(definitions).length > 0) { yield* ctx.core.definitions.register({ sourceId: namespace, - scope: config.scope, + scope: resolvedScope, definitions, }); } @@ -427,15 +458,31 @@ export const graphqlPlugin = definePlugin( } }), - getSource: (namespace, scope) => - ctx.storage.getSource(namespace, scope), - - updateSource: (namespace, scope, input) => - ctx.storage.updateSourceMeta(namespace, scope, { + getSource: (( + namespace: string, + scope?: string, + ) => + ctx.storage.getSource( + namespace, + scope ?? (ctx.scopes[0]!.id as string), + )) as GraphqlPluginExtension["getSource"], + + updateSource: (( + namespace: string, + scopeOrInput: string | GraphqlUpdateSourceInput, + maybeInput?: GraphqlUpdateSourceInput, + ) => { + const [resolvedScope, input] = + typeof scopeOrInput === "string" + ? [scopeOrInput, maybeInput!] + : [ctx.scopes[0]!.id as string, scopeOrInput]; + return ctx.storage.updateSourceMeta(namespace, resolvedScope, { name: input.name?.trim() || undefined, endpoint: input.endpoint, headers: input.headers, - }), + annotationPolicy: input.annotationPolicy, + }); + }) as GraphqlPluginExtension["updateSource"], } satisfies GraphqlPluginExtension; }, @@ -529,26 +576,40 @@ export const graphqlPlugin = definePlugin( // org-level GraphQL source plus a per-user override that // re-registers the same tool ids). Run one listOperationsBySource // per distinct scope so each lookup pins {source_id, scope_id} - // and we don't fall through to the wrong scope's bindings. + // and we don't fall through to the wrong scope's bindings. The + // per-source annotation policy is also scope-owned — pin it the + // same way so a user-scope override doesn't leak onto org-scope + // tools (and vice versa). const scopes = new Set(); for (const row of toolRows as readonly ToolRow[]) { scopes.add(row.scope_id as string); } - const byScope = new Map>(); + const byScope = new Map< + string, + { + bindings: Map; + policy: AnnotationPolicy | undefined; + } + >(); for (const scope of scopes) { const ops = yield* ctx.storage.listOperationsBySource( sourceId, scope, ); - const byId = new Map(); - for (const op of ops) byId.set(op.toolId, op.binding); - byScope.set(scope, byId); + const bindings = new Map(); + for (const op of ops) bindings.set(op.toolId, op.binding); + const source = yield* ctx.storage.getSource(sourceId, scope); + byScope.set(scope, { + bindings, + policy: source?.annotationPolicy, + }); } const out: Record = {}; for (const row of toolRows as readonly ToolRow[]) { - const binding = byScope.get(row.scope_id as string)?.get(row.id); - if (binding) out[row.id] = annotationsFor(binding); + const entry = byScope.get(row.scope_id as string); + const binding = entry?.bindings.get(row.id); + if (binding) out[row.id] = annotationsFor(binding, entry?.policy); } return out; }), diff --git a/packages/plugins/graphql/src/sdk/store.ts b/packages/plugins/graphql/src/sdk/store.ts index 3eb160ee9..23ca513bd 100644 --- a/packages/plugins/graphql/src/sdk/store.ts +++ b/packages/plugins/graphql/src/sdk/store.ts @@ -2,7 +2,7 @@ import { Effect } from "effect"; import { defineSchema, type StorageDeps, type StorageFailure } from "@executor/sdk"; -import { OperationBinding, type HeaderValue } from "./types"; +import { AnnotationPolicy, OperationBinding, type HeaderValue } from "./types"; // --------------------------------------------------------------------------- // Schema — two tables: @@ -18,6 +18,7 @@ export const graphqlSchema = defineSchema({ name: { type: "string", required: true }, endpoint: { type: "string", required: true }, headers: { type: "json", required: false }, + annotation_policy: { type: "json", required: false }, }, }, graphql_operation: { @@ -45,6 +46,7 @@ export interface StoredGraphqlSource { readonly name: string; readonly endpoint: string; readonly headers: Record; + readonly annotationPolicy?: AnnotationPolicy; } export interface StoredOperation { @@ -85,6 +87,25 @@ const decodeHeaders = (value: unknown): Record => { return value as Record; }; +const decodeAnnotationPolicy = (value: unknown): AnnotationPolicy | undefined => { + if (value == null) return undefined; + const data = + typeof value === "string" + ? (JSON.parse(value) as { requireApprovalFor?: readonly string[] }) + : (value as { requireApprovalFor?: readonly string[] }); + return new AnnotationPolicy({ + requireApprovalFor: data.requireApprovalFor + ? ([...data.requireApprovalFor] as ReadonlyArray<"query" | "mutation">) + : undefined, + }); +}; + +const encodeAnnotationPolicy = (policy: AnnotationPolicy): Record => ({ + ...(policy.requireApprovalFor + ? { requireApprovalFor: [...policy.requireApprovalFor] } + : {}), +}); + // --------------------------------------------------------------------------- // Store interface // --------------------------------------------------------------------------- @@ -109,7 +130,13 @@ export interface GraphqlStore { readonly updateSourceMeta: ( namespace: string, scope: string, - patch: { readonly name?: string; readonly endpoint?: string; readonly headers?: Record }, + patch: { + readonly name?: string; + readonly endpoint?: string; + readonly headers?: Record; + /** `null` clears the override; `undefined` leaves as-is. */ + readonly annotationPolicy?: AnnotationPolicy | null; + }, ) => Effect.Effect; readonly getSource: ( @@ -142,13 +169,17 @@ export interface GraphqlStore { export const makeDefaultGraphqlStore = ({ adapter: db, }: StorageDeps): GraphqlStore => { - const rowToSource = (row: Record): StoredGraphqlSource => ({ - namespace: row.id as string, - scope: row.scope_id as string, - name: row.name as string, - endpoint: row.endpoint as string, - headers: decodeHeaders(row.headers), - }); + const rowToSource = (row: Record): StoredGraphqlSource => { + const policy = decodeAnnotationPolicy(row.annotation_policy); + return { + namespace: row.id as string, + scope: row.scope_id as string, + name: row.name as string, + endpoint: row.endpoint as string, + headers: decodeHeaders(row.headers), + ...(policy ? { annotationPolicy: policy } : {}), + }; + }; const rowToOperation = (row: Record): StoredOperation => ({ toolId: row.id as string, @@ -186,6 +217,9 @@ export const makeDefaultGraphqlStore = ({ name: input.name, endpoint: input.endpoint, headers: input.headers as unknown as Record, + ...(input.annotationPolicy + ? { annotation_policy: encodeAnnotationPolicy(input.annotationPolicy) } + : {}), }, forceAllowId: true, }); @@ -219,6 +253,12 @@ export const makeDefaultGraphqlStore = ({ if (patch.headers !== undefined) { update.headers = patch.headers as unknown as Record; } + if (patch.annotationPolicy !== undefined) { + update.annotation_policy = + patch.annotationPolicy === null + ? null + : encodeAnnotationPolicy(patch.annotationPolicy); + } if (Object.keys(update).length === 0) return; yield* db.update({ model: "graphql_source", diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index 008831368..46d03a443 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -7,6 +7,18 @@ import { Schema } from "effect"; export const GraphqlOperationKind = Schema.Literal("query", "mutation"); export type GraphqlOperationKind = typeof GraphqlOperationKind.Type; +// --------------------------------------------------------------------------- +// Annotation policy — per-source override for which operation kinds +// require approval before a tool call runs. Undefined means "use plugin +// defaults" (mutations require approval, queries don't). +// --------------------------------------------------------------------------- + +export class AnnotationPolicy extends Schema.Class( + "GraphqlAnnotationPolicy", +)({ + requireApprovalFor: Schema.optional(Schema.Array(GraphqlOperationKind)), +}) {} + // --------------------------------------------------------------------------- // Extracted field (becomes a tool) // --------------------------------------------------------------------------- diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index f2da04e0f..5b50bc567 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -9,6 +9,7 @@ import { McpToolDiscoveryError, } from "../sdk/errors"; import { McpStoredSourceSchema } from "../sdk/stored-source"; +import { AnnotationPolicy } from "../sdk/types"; // Re-export for handler use export { HttpApiSchema }; @@ -62,6 +63,7 @@ const AddRemoteSourcePayload = Schema.Struct({ queryParams: Schema.optional(StringMap), headers: Schema.optional(StringMap), auth: Schema.optional(AuthPayload), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const AddStdioSourcePayload = Schema.Struct({ @@ -72,6 +74,7 @@ const AddStdioSourcePayload = Schema.Struct({ env: Schema.optional(StringMap), cwd: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const AddSourcePayload = Schema.Union(AddRemoteSourcePayload, AddStdioSourcePayload); @@ -86,6 +89,8 @@ const UpdateSourcePayload = Schema.Struct({ headers: Schema.optional(StringMap), queryParams: Schema.optional(StringMap), auth: Schema.optional(AuthPayload), + // `null` clears a previously-set override; `undefined` leaves as-is. + annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)), }); const UpdateSourceResponse = Schema.Struct({ diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index befdd1b92..c62b208ef 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -97,6 +97,8 @@ const toSourceConfig = ( payload: { transport: "remote" | "stdio" } & Record, scope: string, ): McpSourceConfig => { + const annotationPolicy = (payload as { annotationPolicy?: McpSourceConfig["annotationPolicy"] }) + .annotationPolicy; if (payload.transport === "stdio") { const p = payload as { transport: "stdio"; @@ -116,6 +118,7 @@ const toSourceConfig = ( env: p.env, cwd: p.cwd, namespace: p.namespace, + annotationPolicy, }; } @@ -149,6 +152,7 @@ const toSourceConfig = ( headers: p.headers, namespace: p.namespace, auth: auth as McpSourceConfig extends { auth?: infer A } ? A : never, + annotationPolicy, }; }; @@ -237,6 +241,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand headers: payload.headers, queryParams: payload.queryParams, auth: payload.auth as McpUpdateSourceInput["auth"], + annotationPolicy: payload.annotationPolicy, }); return { updated: true }; })), diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 801abf377..c1e27cfc3 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -25,6 +25,7 @@ import { SourceFavicon } from "@executor/react/components/source-favicon"; import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { Textarea } from "@executor/react/components/textarea"; import { HeadersList } from "@executor/react/plugins/headers-list"; +import { ApprovalPolicySwitch } from "@executor/react/plugins/approval-policy-field"; import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; import { displayNameFromUrl, @@ -367,6 +368,7 @@ export default function AddMcpSource(props: { }, ]); const [remoteHeaders, setRemoteHeaders] = useState([]); + const [annotationPolicy, setAnnotationPolicy] = useState(undefined); const probe = "probe" in state ? state.probe : null; const tokens = "tokens" in state ? state.tokens : null; @@ -546,6 +548,9 @@ export default function AddMcpSource(props: { endpoint: state.url.trim(), auth, ...(Object.keys(headers).length > 0 ? { headers } : {}), + ...(annotationPolicy !== undefined + ? { annotationPolicy: { requireApprovalForAll: annotationPolicy } } + : {}), }, reactivityKeys: sourceWriteKeys, }); @@ -570,6 +575,7 @@ export default function AddMcpSource(props: { props, scopeId, beginAdd, + annotationPolicy, ]); // ---- Stdio actions ---- @@ -620,6 +626,9 @@ export default function AddMcpSource(props: { command: cmd, args: parseStdioArgs(stdioArgs), env: parseStdioEnv(stdioEnv), + ...(annotationPolicy !== undefined + ? { annotationPolicy: { requireApprovalForAll: annotationPolicy } } + : {}), }, reactivityKeys: sourceWriteKeys, }); @@ -630,7 +639,17 @@ export default function AddMcpSource(props: { } finally { placeholder.done(); } - }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]); + }, [ + stdioCommand, + stdioArgs, + stdioEnv, + stdioIdentity, + doAdd, + scopeId, + props, + beginAdd, + annotationPolicy, + ]); // ---- Render ---- @@ -955,6 +974,18 @@ export default function AddMcpSource(props: { )} + {/* Approval policy override */} + {probe && ( + + )} + {/* Error (OAuth / add source). Probe errors show inline on the field. */} {otherError && (
@@ -1039,6 +1070,15 @@ export default function AddMcpSource(props: { namePlaceholder="My MCP Server" /> + + {/* Stdio error */} {stdioError && (
diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 17d52e469..c7666620f 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -16,6 +16,7 @@ import { import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { Badge } from "@executor/react/components/badge"; +import { ApprovalPolicySwitch } from "@executor/react/plugins/approval-policy-field"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; // --------------------------------------------------------------------------- @@ -50,6 +51,9 @@ function RemoteEditForm(props: { value, })), ); + const [annotationPolicy, setAnnotationPolicy] = useState( + props.initial.annotationPolicy?.requireApprovalForAll, + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); @@ -89,6 +93,10 @@ function RemoteEditForm(props: { name: identity.name.trim() || undefined, endpoint: endpoint.trim() || undefined, headers: headersObj, + annotationPolicy: + annotationPolicy === undefined + ? null + : { requireApprovalForAll: annotationPolicy }, }, reactivityKeys: sourceWriteKeys, }); @@ -170,6 +178,18 @@ function RemoteEditForm(props: { + { + setAnnotationPolicy(next); + setDirty(true); + }} + /> + {error && (

{error}

@@ -198,13 +218,45 @@ function StdioReadOnly(props: { onSave: () => void; }) { const { command, args } = props.initial.config; + const scopeId = useScope(); + const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" }); + const [annotationPolicy, setAnnotationPolicy] = useState( + props.initial.annotationPolicy?.requireApprovalForAll, + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [dirty, setDirty] = useState(false); + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await doUpdate({ + path: { scopeId, namespace: props.sourceId }, + payload: { + annotationPolicy: + annotationPolicy === undefined + ? null + : { requireApprovalForAll: annotationPolicy }, + }, + reactivityKeys: sourceWriteKeys, + }); + setDirty(false); + props.onSave(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update source"); + } finally { + setSaving(false); + } + }; + return (

Edit MCP Source

- Stdio MCP sources cannot be edited in the UI. Modify the executor.jsonc config file - directly. + Stdio command and arguments are managed via the executor.jsonc config file. Approval + policy can be changed here.

@@ -220,8 +272,31 @@ function StdioReadOnly(props: {
-
- + { + setAnnotationPolicy(next); + setDirty(true); + }} + /> + + {error && ( +
+

{error}

+
+ )} + +
+ +
); diff --git a/packages/plugins/mcp/src/sdk/binding-store.ts b/packages/plugins/mcp/src/sdk/binding-store.ts index b1acceb91..3cd2f645a 100644 --- a/packages/plugins/mcp/src/sdk/binding-store.ts +++ b/packages/plugins/mcp/src/sdk/binding-store.ts @@ -11,7 +11,7 @@ import { type StorageFailure, } from "@executor/sdk"; -import { McpToolBinding, McpStoredSourceData } from "./types"; +import { AnnotationPolicy, McpToolBinding, McpStoredSourceData } from "./types"; import { McpOAuthSession } from "./oauth"; // --------------------------------------------------------------------------- @@ -25,6 +25,7 @@ export const mcpSchema = defineSchema({ scope_id: { type: "string", required: true, index: true }, name: { type: "string", required: true }, config: { type: "json", required: true }, + annotation_policy: { type: "json", required: false }, created_at: { type: "date", required: true }, }, }, @@ -69,6 +70,9 @@ const encodeSourceData = Schema.encodeSync(McpStoredSourceData); const decodeBinding = Schema.decodeUnknownSync(McpToolBinding); const encodeBinding = Schema.encodeSync(McpToolBinding); +const decodeAnnotationPolicy = Schema.decodeUnknownSync(AnnotationPolicy); +const encodeAnnotationPolicy = Schema.encodeSync(AnnotationPolicy); + const decodeSession = Schema.decodeUnknownSync(McpOAuthSession); const encodeSession = Schema.encodeSync(McpOAuthSession); @@ -93,6 +97,9 @@ export interface McpStoredSource { readonly scope: string; readonly name: string; readonly config: McpStoredSourceData; + /** Per-source override of the default approval policy. Undefined means + * "use the plugin default" (no forced approval). */ + readonly annotationPolicy?: AnnotationPolicy; } // --------------------------------------------------------------------------- @@ -150,6 +157,23 @@ export interface McpBindingStore { scope: string, ) => Effect.Effect; + /** + * Patch sibling fields of an existing source without touching its tool + * bindings. `name` is renamed when a non-empty string is supplied. + * `annotationPolicy` follows the three-way null/undefined convention: + * `undefined` leaves it alone, `null` clears the override, a concrete + * value writes the override. + */ + readonly updateSourceMeta: ( + namespace: string, + scope: string, + patch: { + readonly name?: string; + readonly config?: McpStoredSourceData; + readonly annotationPolicy?: AnnotationPolicy | null; + }, + ) => Effect.Effect; + readonly putOAuthSession: ( sessionId: string, scope: string, @@ -214,12 +238,19 @@ export const makeMcpStore = ({ listSources: () => Effect.gen(function* () { const rows = yield* db.findMany({ model: "mcp_source" }); - return rows.map((row) => ({ - namespace: row.id, - scope: row.scope_id, - name: row.name, - config: decodeSourceData(coerceJson(row.config)), - })); + return rows.map((row) => { + const policyRaw = row.annotation_policy; + return { + namespace: row.id, + scope: row.scope_id, + name: row.name, + config: decodeSourceData(coerceJson(row.config)), + annotationPolicy: + policyRaw == null + ? undefined + : decodeAnnotationPolicy(coerceJson(policyRaw)), + }; + }); }), getSource: (namespace, scope) => @@ -232,11 +263,16 @@ export const makeMcpStore = ({ ], }); if (!row) return null; + const policyRaw = row.annotation_policy; return { namespace: row.id, scope: row.scope_id, name: row.name, config: decodeSourceData(coerceJson(row.config)), + annotationPolicy: + policyRaw == null + ? undefined + : decodeAnnotationPolicy(coerceJson(policyRaw)), }; }), @@ -270,12 +306,67 @@ export const makeMcpStore = ({ scope_id: source.scope, name: source.name, config: encodeSourceData(source.config), + annotation_policy: source.annotationPolicy + ? (encodeAnnotationPolicy(source.annotationPolicy) as unknown as Record< + string, + unknown + >) + : undefined, created_at: now, }, forceAllowId: true, }); }), + updateSourceMeta: (namespace, scope, patch) => + Effect.gen(function* () { + const existing = yield* db.findOne({ + model: "mcp_source", + where: [ + { field: "id", value: namespace }, + { field: "scope_id", value: scope }, + ], + }); + if (!existing) return; + + const name = + patch.name !== undefined && patch.name.trim().length > 0 + ? patch.name + : existing.name; + const config = + patch.config !== undefined + ? encodeSourceData(patch.config) + : existing.config; + + // Three-way null/undefined: `undefined` in the patch means + // "leave the existing column alone"; `null` clears the column + // explicitly; a concrete value writes the new policy. + const annotationPolicyUpdate = + patch.annotationPolicy === undefined + ? undefined + : patch.annotationPolicy === null + ? null + : (encodeAnnotationPolicy(patch.annotationPolicy) as unknown as Record< + string, + unknown + >); + + yield* db.update({ + model: "mcp_source", + where: [ + { field: "id", value: namespace }, + { field: "scope_id", value: scope }, + ], + update: { + name, + config, + ...(annotationPolicyUpdate !== undefined + ? { annotation_policy: annotationPolicyUpdate } + : {}), + }, + }); + }), + removeSource: (namespace, scope) => Effect.gen(function* () { yield* db.deleteMany({ diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index a45ba710d..73c8d55b6 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; +import * as http from "node:http"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { z } from "zod"; import { createExecutor, makeTestConfig, Scope, ScopeId } from "@executor/sdk"; @@ -356,4 +360,265 @@ describe("mcpPlugin", () => { } }), ); + + // ------------------------------------------------------------------------- + // Annotation policy override — per-source `requireApprovalForAll` toggle. + // + // MCP tools default to `requiresApproval: false` because the server + // handles approval mid-invocation via elicitation. When an admin flips + // `{ requireApprovalForAll: true }` on the source, every tool from + // that source picks up a pre-call approval gate instead. + // + // We spin up a real in-process MCP server so `executor.tools.list()` + // returns actual tool rows — that's what `resolveAnnotations` is keyed + // on. The server is intentionally minimal: two tools, no elicitation, + // no auth. + // ------------------------------------------------------------------------- + + type AnnotationTestServer = { + readonly url: string; + readonly httpServer: http.Server; + }; + + const makeAnnotationTestServer = (): Effect.Effect< + AnnotationTestServer, + Error, + never + > => + Effect.async((resume) => { + const transports = new Map(); + + const buildServer = () => { + const server = new McpServer( + { name: "annotation-test-server", version: "1.0.0" }, + { capabilities: {} }, + ); + server.registerTool( + "echo_a", + { description: "echo a", inputSchema: { value: z.string() } }, + async ({ value }: { value: string }) => ({ + content: [{ type: "text" as const, text: value }], + }), + ); + server.registerTool( + "echo_b", + { description: "echo b", inputSchema: { value: z.string() } }, + async ({ value }: { value: string }) => ({ + content: [{ type: "text" as const, text: value }], + }), + ); + return server; + }; + + const httpServer = http.createServer(async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (sessionId) { + const transport = transports.get(sessionId); + if (!transport) { + res.writeHead(404); + res.end("Session not found"); + return; + } + await transport.handleRequest(req, res); + return; + } + const mcpServer = buildServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (sid) => { + transports.set(sid, transport); + }, + }); + await mcpServer.connect(transport); + await transport.handleRequest(req, res); + }); + + httpServer.listen(0, () => { + const addr = httpServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + httpServer, + }), + ); + }); + }); + + const withAnnotationServer = Effect.acquireRelease( + makeAnnotationTestServer(), + ({ httpServer }) => + Effect.sync(() => { + httpServer.close(); + }), + ); + + it.scoped("default — no annotationPolicy leaves tools auto-approved", () => + Effect.gen(function* () { + const server = yield* withAnnotationServer; + const executor = yield* createExecutor( + makeTestConfig({ plugins: [mcpPlugin()] as const }), + ); + + yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "annot-default", + endpoint: server.url, + namespace: "annot_default", + }); + + const tools = yield* executor.tools.list(); + const fromSource = tools.filter((t) => t.sourceId === "annot_default"); + expect(fromSource.length).toBeGreaterThanOrEqual(2); + for (const t of fromSource) { + expect(t.annotations?.requiresApproval ?? false).toBe(false); + } + + const stored = yield* executor.mcp.getSource("annot_default", "test-scope"); + expect(stored?.annotationPolicy).toBeUndefined(); + }), + ); + + it.scoped( + "override on — requireApprovalForAll:true forces approval on every tool", + () => + Effect.gen(function* () { + const server = yield* withAnnotationServer; + const executor = yield* createExecutor( + makeTestConfig({ plugins: [mcpPlugin()] as const }), + ); + + yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "annot-on", + endpoint: server.url, + namespace: "annot_on", + annotationPolicy: { requireApprovalForAll: true }, + }); + + const tools = yield* executor.tools.list(); + const fromSource = tools.filter((t) => t.sourceId === "annot_on"); + expect(fromSource.length).toBeGreaterThanOrEqual(2); + for (const t of fromSource) { + expect(t.annotations?.requiresApproval).toBe(true); + expect(t.annotations?.approvalDescription).toBeTypeOf("string"); + expect(t.annotations?.approvalDescription?.length ?? 0).toBeGreaterThan( + 0, + ); + } + + const stored = yield* executor.mcp.getSource("annot_on", "test-scope"); + expect(stored?.annotationPolicy?.requireApprovalForAll).toBe(true); + }), + ); + + it.scoped( + "override off (explicit false) — same approval shape as default but persists", + () => + Effect.gen(function* () { + const server = yield* withAnnotationServer; + const executor = yield* createExecutor( + makeTestConfig({ plugins: [mcpPlugin()] as const }), + ); + + yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "annot-off", + endpoint: server.url, + namespace: "annot_off", + annotationPolicy: { requireApprovalForAll: false }, + }); + + const tools = yield* executor.tools.list(); + const fromSource = tools.filter((t) => t.sourceId === "annot_off"); + expect(fromSource.length).toBeGreaterThanOrEqual(2); + for (const t of fromSource) { + expect(t.annotations?.requiresApproval ?? false).toBe(false); + } + + // Round-trip distinguishes an explicit-off from absent. + const stored = yield* executor.mcp.getSource("annot_off", "test-scope"); + expect(stored?.annotationPolicy).toBeDefined(); + expect(stored?.annotationPolicy?.requireApprovalForAll).toBe(false); + }), + ); + + it.scoped( + "updateSource(annotationPolicy: null) clears the override", + () => + Effect.gen(function* () { + const server = yield* withAnnotationServer; + const executor = yield* createExecutor( + makeTestConfig({ plugins: [mcpPlugin()] as const }), + ); + + yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "annot-clear", + endpoint: server.url, + namespace: "annot_clear", + annotationPolicy: { requireApprovalForAll: true }, + }); + + // Sanity — before the clear, every tool requires approval. + let tools = yield* executor.tools.list(); + let fromSource = tools.filter((t) => t.sourceId === "annot_clear"); + for (const t of fromSource) { + expect(t.annotations?.requiresApproval).toBe(true); + } + + yield* executor.mcp.updateSource("annot_clear", "test-scope", { + annotationPolicy: null, + }); + + tools = yield* executor.tools.list(); + fromSource = tools.filter((t) => t.sourceId === "annot_clear"); + for (const t of fromSource) { + expect(t.annotations?.requiresApproval ?? false).toBe(false); + } + + const stored = yield* executor.mcp.getSource("annot_clear", "test-scope"); + expect(stored?.annotationPolicy).toBeUndefined(); + }), + ); + + it.scoped( + "updateSource without annotationPolicy key leaves the override alone", + () => + Effect.gen(function* () { + const server = yield* withAnnotationServer; + const executor = yield* createExecutor( + makeTestConfig({ plugins: [mcpPlugin()] as const }), + ); + + yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "annot-keep", + endpoint: server.url, + namespace: "annot_keep", + annotationPolicy: { requireApprovalForAll: true }, + }); + + // Update a sibling field only — policy must survive unchanged. + yield* executor.mcp.updateSource("annot_keep", "test-scope", { + name: "annot-keep-renamed", + }); + + const stored = yield* executor.mcp.getSource("annot_keep", "test-scope"); + expect(stored?.name).toBe("annot-keep-renamed"); + expect(stored?.annotationPolicy?.requireApprovalForAll).toBe(true); + + const tools = yield* executor.tools.list(); + const fromSource = tools.filter((t) => t.sourceId === "annot_keep"); + expect(fromSource.length).toBeGreaterThanOrEqual(2); + for (const t of fromSource) { + expect(t.annotations?.requiresApproval).toBe(true); + } + }), + ); }); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 7bbf47391..9ebe9f49b 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -42,7 +42,12 @@ import { type McpToolManifestEntry, } from "./manifest"; import { exchangeMcpOAuthCode, startMcpOAuthAuthorization } from "./oauth"; -import { McpToolBinding, type McpConnectionAuth, type McpStoredSourceData } from "./types"; +import { + AnnotationPolicy, + McpToolBinding, + type McpConnectionAuth, + type McpStoredSourceData, +} from "./types"; import { SECRET_REF_PREFIX, @@ -74,6 +79,9 @@ export interface McpRemoteSourceConfig extends McpSourceScopeField { readonly headers?: Record; readonly namespace?: string; readonly auth?: McpConnectionAuth; + /** Per-source override for the default annotation policy. Omit to + * leave MCP tools auto-approved (the plugin default). */ + readonly annotationPolicy?: AnnotationPolicy; } export interface McpStdioSourceConfig extends McpSourceScopeField { @@ -84,6 +92,9 @@ export interface McpStdioSourceConfig extends McpSourceScopeField { readonly env?: Record; readonly cwd?: string; readonly namespace?: string; + /** Per-source override for the default annotation policy. Omit to + * leave MCP tools auto-approved (the plugin default). */ + readonly annotationPolicy?: AnnotationPolicy; } export type McpSourceConfig = McpRemoteSourceConfig | McpStdioSourceConfig; @@ -162,6 +173,9 @@ export interface McpUpdateSourceInput { readonly headers?: Record; readonly queryParams?: Record; readonly auth?: McpConnectionAuth; + /** `null` clears a previously-set override; `undefined` leaves as-is; + * a concrete policy replaces the stored policy. */ + readonly annotationPolicy?: AnnotationPolicy | null; } // --------------------------------------------------------------------------- @@ -678,6 +692,7 @@ export const mcpPlugin = definePlugin( scope: config.scope, name: sourceName, config: sd, + annotationPolicy: config.annotationPolicy, }); yield* ctx.storage.putBindings( @@ -1007,24 +1022,40 @@ export const mcpPlugin = definePlugin( ) => Effect.gen(function* () { const existing = yield* ctx.storage.getSource(namespace, scope); - if (!existing || existing.config.transport !== "remote") return; - - const remote = existing.config; - const updatedConfig: McpStoredSourceData = { - ...remote, - ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}), - ...(input.headers !== undefined ? { headers: input.headers } : {}), - ...(input.auth !== undefined ? { auth: input.auth } : {}), - ...(input.queryParams !== undefined - ? { queryParams: input.queryParams } - : {}), - }; + if (!existing) return; + + // Only the config portion is transport-specific; annotation + // policy applies to every source kind and is patched even + // when no transport-level fields change. + let updatedConfig: McpStoredSourceData | undefined; + if (existing.config.transport === "remote") { + const remote = existing.config; + const touchesConfig = + input.endpoint !== undefined || + input.headers !== undefined || + input.auth !== undefined || + input.queryParams !== undefined; + if (touchesConfig) { + updatedConfig = { + ...remote, + ...(input.endpoint !== undefined + ? { endpoint: input.endpoint } + : {}), + ...(input.headers !== undefined + ? { headers: input.headers } + : {}), + ...(input.auth !== undefined ? { auth: input.auth } : {}), + ...(input.queryParams !== undefined + ? { queryParams: input.queryParams } + : {}), + }; + } + } - yield* ctx.storage.putSource({ - namespace, - scope, - name: input.name?.trim() || existing.name, + yield* ctx.storage.updateSourceMeta(namespace, scope, { + name: input.name?.trim() || undefined, config: updatedConfig, + annotationPolicy: input.annotationPolicy, }); }).pipe( Effect.withSpan("mcp.plugin.update_source", { @@ -1186,13 +1217,52 @@ export const mcpPlugin = definePlugin( }), ), - // MCP tools never require approval at the tool level — elicitation is + // MCP tools default to no tool-level approval — elicitation is // handled mid-invocation by the server via the elicit capability. - resolveAnnotations: ({ toolRows }) => - Effect.sync(() => { - const out: Record = {}; + // Per-source admins can flip `annotationPolicy.requireApprovalForAll` + // to re-introduce a pre-call approval gate for every tool. + resolveAnnotations: ({ ctx, sourceId, toolRows }) => + Effect.gen(function* () { + // toolRows for a single (plugin_id, source_id) group can still + // straddle multiple scopes when the source is shadowed (e.g. + // an org-level MCP source plus a per-user override that + // re-registers the same tool ids). Per-source annotation + // policy is scope-owned — pin the source lookup per distinct + // scope so a user-scope override doesn't leak onto org-scope + // tools (and vice versa). + const scopes = new Set(); + for (const row of toolRows) { + scopes.add(row.scope_id as string); + } + const byScope = new Map< + string, + { name: string; policy: AnnotationPolicy | undefined } + >(); + for (const scope of scopes) { + const source = yield* ctx.storage.getSource(sourceId, scope); + byScope.set(scope, { + name: source?.name ?? sourceId, + policy: source?.annotationPolicy, + }); + } + + const out: Record< + string, + { + readonly requiresApproval: boolean; + readonly approvalDescription?: string; + } + > = {}; for (const row of toolRows) { - out[row.id] = { requiresApproval: false }; + const entry = byScope.get(row.scope_id as string); + const requireAll = + entry?.policy?.requireApprovalForAll === true; + out[row.id] = requireAll + ? { + requiresApproval: true, + approvalDescription: `Source "${entry?.name ?? sourceId}" requires approval for every MCP tool call`, + } + : { requiresApproval: false }; } return out; }), diff --git a/packages/plugins/mcp/src/sdk/stored-source.ts b/packages/plugins/mcp/src/sdk/stored-source.ts index 9c543ef27..fc425ffcb 100644 --- a/packages/plugins/mcp/src/sdk/stored-source.ts +++ b/packages/plugins/mcp/src/sdk/stored-source.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { McpStoredSourceData } from "./types"; +import { AnnotationPolicy, McpStoredSourceData } from "./types"; // --------------------------------------------------------------------------- // Stored source — the shape persisted by the binding store and exposed @@ -11,6 +11,7 @@ export class McpStoredSourceSchema extends Schema.Class(" namespace: Schema.String, name: Schema.String, config: McpStoredSourceData, + annotationPolicy: Schema.optional(AnnotationPolicy), }) {} export type McpStoredSourceSchemaType = typeof McpStoredSourceSchema.Type; diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index f84e80f7b..231b3e723 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -107,3 +107,16 @@ export class McpToolBinding extends Schema.Class("McpToolBinding inputSchema: Schema.optional(Schema.Unknown), outputSchema: Schema.optional(Schema.Unknown), }) {} + +// --------------------------------------------------------------------------- +// Annotation policy — per-source override for whether MCP tool calls from +// this source require approval. MCP tools default to no approval because +// servers handle elicitation mid-invocation; flipping this to `true` +// reintroduces a pre-call gate for every tool from the source. +// --------------------------------------------------------------------------- + +export class AnnotationPolicy extends Schema.Class( + "McpAnnotationPolicy", +)({ + requireApprovalForAll: Schema.optional(Schema.Boolean), +}) {} diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 388ef18f3..8c1a6480d 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -10,7 +10,7 @@ import { } from "../sdk/errors"; import { SpecPreview } from "../sdk/preview"; import { StoredSourceSchema } from "../sdk/store"; -import { OAuth2Auth } from "../sdk/types"; +import { AnnotationPolicy, OAuth2Auth } from "../sdk/types"; // --------------------------------------------------------------------------- // Params @@ -30,6 +30,7 @@ const AddSpecPayload = Schema.Struct({ namespace: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), oauth2: Schema.optional(OAuth2Auth), + annotationPolicy: Schema.optional(AnnotationPolicy), }); const PreviewSpecPayload = Schema.Struct({ @@ -40,6 +41,8 @@ const UpdateSourcePayload = Schema.Struct({ name: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), + // `null` clears a previously-set override; `undefined` leaves as-is. + annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)), }); const UpdateSourceResponse = Schema.Struct({ diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index a69de95e7..0574d435d 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -71,6 +71,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope namespace: payload.namespace, headers: payload.headers as Record | undefined, oauth2: payload.oauth2, + annotationPolicy: payload.annotationPolicy, }); return { toolCount: result.toolCount, @@ -91,6 +92,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope name: payload.name, baseUrl: payload.baseUrl, headers: payload.headers as Record | undefined, + annotationPolicy: payload.annotationPolicy, } as OpenApiUpdateSourceInput); return { updated: true }; })), diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 1cb3fac09..c40d2b818 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -8,6 +8,10 @@ import { useScope } from "@executor/react/api/scope-context"; import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; import { usePendingSources } from "@executor/react/api/optimistic"; import { HeadersList } from "@executor/react/plugins/headers-list"; +import { + ApprovalPolicyToggles, + HTTP_METHOD_TOKENS, +} from "@executor/react/plugins/approval-policy-field"; import { CreatableSecretPicker, matchPresetKey, @@ -188,6 +192,11 @@ export default function AddOpenApiSource(props: { const [oauth2Error, setOauth2Error] = useState(null); const oauthCleanup = useRef<(() => void) | null>(null); + // Annotation policy override — `undefined` means "use plugin defaults". + const [annotationPolicy, setAnnotationPolicy] = useState( + undefined, + ); + // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); @@ -501,6 +510,9 @@ export default function AddOpenApiSource(props: { baseUrl: resolvedBaseUrl || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), + ...(annotationPolicy !== undefined + ? { annotationPolicy: { requireApprovalFor: annotationPolicy } } + : {}), }, reactivityKeys: sourceWriteKeys, }); @@ -947,6 +959,13 @@ export default function AddOpenApiSource(props: { )} + + {/* Add error */} {addError && (
diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 205d7b8f9..bf2a26611 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -32,6 +32,10 @@ import { type HeaderState, } from "@executor/react/plugins/secret-header-auth"; import { HeadersList } from "@executor/react/plugins/headers-list"; +import { + ApprovalPolicyToggles, + HTTP_METHOD_TOKENS, +} from "@executor/react/plugins/approval-policy-field"; import { SourceIdentityFields, useSourceIdentity, @@ -368,6 +372,13 @@ function EditForm(props: { headerValueToState(name, value), ), ); + // `undefined` = using plugin defaults; any array (even empty) = explicit override. + const [annotationPolicy, setAnnotationPolicy] = useState( + () => { + const stored = props.initial.config.annotationPolicy?.requireApprovalFor; + return stored ? [...stored] : undefined; + }, + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); @@ -396,6 +407,10 @@ function EditForm(props: { name: identity.name.trim() || undefined, baseUrl: baseUrl.trim() || undefined, headers: headersFromState(headers), + annotationPolicy: + annotationPolicy === undefined + ? null + : { requireApprovalFor: annotationPolicy }, }, reactivityKeys: sourceWriteKeys, }); @@ -462,6 +477,16 @@ function EditForm(props: { /> )} + { + setAnnotationPolicy(next); + setDirty(true); + }} + description="Choose which HTTP methods require approval before a tool call runs." + /> + {error && (

{error}

diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 339431826..f0e6d7137 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -299,7 +299,7 @@ export const invokeWithLayer = ( }; // --------------------------------------------------------------------------- -// Derive annotations from HTTP method +// Derive annotations from HTTP method — with per-source policy override. // --------------------------------------------------------------------------- const DEFAULT_REQUIRE_APPROVAL = new Set(["post", "put", "patch", "delete"]); @@ -307,7 +307,7 @@ const DEFAULT_REQUIRE_APPROVAL = new Set(["post", "put", "patch", "delete"]); export const annotationsForOperation = ( method: string, pathTemplate: string, - policy?: { readonly requireApprovalFor?: readonly string[] }, + policy?: { readonly requireApprovalFor?: readonly string[] } | null, ): { requiresApproval?: boolean; approvalDescription?: string } => { const m = method.toLowerCase(); const requireSet = policy?.requireApprovalFor diff --git a/packages/plugins/openapi/src/sdk/plugin.annotation-policy.test.ts b/packages/plugins/openapi/src/sdk/plugin.annotation-policy.test.ts new file mode 100644 index 000000000..748aa6832 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/plugin.annotation-policy.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + createExecutor, + definePlugin, + makeTestConfig, + type SecretProvider, +} from "@executor/sdk"; + +import { annotationsForOperation } from "./invoke"; +import { AnnotationPolicy } from "./types"; +import { openApiPlugin } from "./plugin"; + +const TEST_SCOPE = "test-scope"; + +// --------------------------------------------------------------------------- +// Plain OpenAPI spec with GET / POST / DELETE operations so we can observe +// how per-source annotation policy overrides interact with each HTTP method. +// Hand-rolled JSON rather than Effect's HttpApi so the test stays fast and +// doesn't need a running server — we only look at tool annotations on +// executor.tools.list(), no invocations required. +// --------------------------------------------------------------------------- + +const specJson = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Annotation Policy Test API", version: "1.0.0" }, + paths: { + "/items": { + get: { + operationId: "listItems", + responses: { "200": { description: "ok" } }, + }, + post: { + operationId: "createItem", + responses: { "200": { description: "ok" } }, + }, + }, + "/items/{id}": { + delete: { + operationId: "deleteItem", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +const memoryProvider: SecretProvider = (() => { + const store = new Map(); + return { + key: "memory", + writable: true, + get: (id, scope) => + Effect.sync(() => store.get(`${scope}\u0000${id}`) ?? null), + set: (id, value, scope) => + Effect.sync(() => { + store.set(`${scope}\u0000${id}`, value); + }), + delete: (id, scope) => + Effect.sync(() => store.delete(`${scope}\u0000${id}`)), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((k) => { + const name = k.split("\u0000", 2)[1] ?? k; + return { id: name, name }; + }), + ), + }; +})(); + +const memorySecretsPlugin = definePlugin(() => ({ + id: "memory-secrets" as const, + storage: () => ({}), + secretProviders: [memoryProvider], +})); + +// --------------------------------------------------------------------------- +// Pure unit tests for annotationsForOperation — no executor needed. +// --------------------------------------------------------------------------- + +describe("annotationsForOperation", () => { + it("applies defaults: GET is fine, POST requires approval", () => { + expect(annotationsForOperation("get", "/items")).toEqual({}); + expect(annotationsForOperation("post", "/items")).toEqual({ + requiresApproval: true, + approvalDescription: "POST /items", + }); + expect(annotationsForOperation("delete", "/items/{id}")).toEqual({ + requiresApproval: true, + approvalDescription: "DELETE /items/{id}", + }); + }); + + it("override replaces default set wholesale — GET now requires, DELETE does not", () => { + const policy = { requireApprovalFor: ["get", "post"] as const }; + expect(annotationsForOperation("get", "/items", policy)).toEqual({ + requiresApproval: true, + approvalDescription: "GET /items", + }); + expect(annotationsForOperation("post", "/items", policy)).toEqual({ + requiresApproval: true, + approvalDescription: "POST /items", + }); + // DELETE is NOT in the list → does not require approval anymore. + expect(annotationsForOperation("delete", "/items/{id}", policy)).toEqual({}); + }); + + it("empty list means nothing requires approval", () => { + const policy = { requireApprovalFor: [] as readonly string[] }; + expect(annotationsForOperation("get", "/items", policy)).toEqual({}); + expect(annotationsForOperation("post", "/items", policy)).toEqual({}); + expect(annotationsForOperation("delete", "/items/{id}", policy)).toEqual({}); + }); + + it("policy with undefined requireApprovalFor falls back to defaults", () => { + const policy = {} as { requireApprovalFor?: readonly string[] }; + expect(annotationsForOperation("get", "/items", policy)).toEqual({}); + expect(annotationsForOperation("post", "/items", policy)).toEqual({ + requiresApproval: true, + approvalDescription: "POST /items", + }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests through the full executor.openapi.addSpec / +// updateSource / tools.list path. +// --------------------------------------------------------------------------- + +const makeExecutor = () => + createExecutor( + makeTestConfig({ + plugins: [openApiPlugin(), memorySecretsPlugin()] as const, + }), + ); + +const findTool = ( + tools: readonly { readonly id: string }[], + id: string, +): (typeof tools)[number] | undefined => tools.find((t) => t.id === id); + +describe("openapi per-source annotation policy", () => { + it.effect("default policy: GET ok, POST requires approval", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + + yield* executor.openapi.addSpec({ + spec: specJson, + scope: TEST_SCOPE, + namespace: "defaults", + baseUrl: "", + }); + + const tools = yield* executor.tools.list(); + const listTool = findTool(tools, "defaults.items.listItems") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + const createTool = findTool(tools, "defaults.items.createItem") as + | { + annotations?: { + requiresApproval?: boolean; + approvalDescription?: string; + }; + } + | undefined; + + expect(listTool).toBeDefined(); + expect(createTool).toBeDefined(); + expect(listTool!.annotations?.requiresApproval).toBeFalsy(); + expect(createTool!.annotations?.requiresApproval).toBe(true); + expect(createTool!.annotations?.approvalDescription).toBe("POST /items"); + }), + ); + + it.effect( + "override requireApprovalFor=[get,post]: GET now approves, DELETE does not", + () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + + yield* executor.openapi.addSpec({ + spec: specJson, + scope: TEST_SCOPE, + namespace: "override", + baseUrl: "", + annotationPolicy: new AnnotationPolicy({ + requireApprovalFor: ["get", "post"], + }), + }); + + const tools = yield* executor.tools.list(); + const listTool = findTool(tools, "override.items.listItems") as + | { + annotations?: { + requiresApproval?: boolean; + approvalDescription?: string; + }; + } + | undefined; + const deleteTool = findTool(tools, "override.items.deleteItem") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + + expect(listTool?.annotations?.requiresApproval).toBe(true); + expect(listTool?.annotations?.approvalDescription).toBe("GET /items"); + expect(deleteTool?.annotations?.requiresApproval).toBeFalsy(); + }), + ); + + it.effect("empty requireApprovalFor: nothing requires approval", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + + yield* executor.openapi.addSpec({ + spec: specJson, + scope: TEST_SCOPE, + namespace: "permissive", + baseUrl: "", + annotationPolicy: new AnnotationPolicy({ requireApprovalFor: [] }), + }); + + const tools = yield* executor.tools.list(); + const createTool = findTool(tools, "permissive.items.createItem") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + const deleteTool = findTool(tools, "permissive.items.deleteItem") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + + expect(createTool?.annotations?.requiresApproval).toBeFalsy(); + expect(deleteTool?.annotations?.requiresApproval).toBeFalsy(); + }), + ); + + it.effect("updateSource(null) clears override -> defaults return", () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + + yield* executor.openapi.addSpec({ + spec: specJson, + scope: TEST_SCOPE, + namespace: "togglable", + baseUrl: "", + annotationPolicy: new AnnotationPolicy({ requireApprovalFor: [] }), + }); + + // Before clear: POST should NOT require approval (empty list wins). + const before = yield* executor.tools.list(); + const createBefore = findTool(before, "togglable.items.createItem") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + expect(createBefore?.annotations?.requiresApproval).toBeFalsy(); + + yield* executor.openapi.updateSource("togglable", TEST_SCOPE, { + annotationPolicy: null, + }); + + // Sanity — the stored source should no longer carry a policy. + const clearedSource = yield* executor.openapi.getSource("togglable", TEST_SCOPE); + expect(clearedSource?.config.annotationPolicy).toBeUndefined(); + + // After clear: back to defaults — POST requires approval again. + const after = yield* executor.tools.list(); + const createAfter = findTool(after, "togglable.items.createItem") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + const listAfter = findTool(after, "togglable.items.listItems") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + expect(createAfter?.annotations?.requiresApproval).toBe(true); + expect(listAfter?.annotations?.requiresApproval).toBeFalsy(); + }), + ); + + it.effect( + "updateSource with undefined annotationPolicy leaves existing override intact", + () => + Effect.gen(function* () { + const executor = yield* makeExecutor(); + + yield* executor.openapi.addSpec({ + spec: specJson, + scope: TEST_SCOPE, + namespace: "sticky", + baseUrl: "", + annotationPolicy: new AnnotationPolicy({ + requireApprovalFor: ["get"], + }), + }); + + // Name-only update — no annotationPolicy key means "leave as-is". + yield* executor.openapi.updateSource("sticky", TEST_SCOPE, { name: "Renamed" }); + + const tools = yield* executor.tools.list(); + const listTool = findTool(tools, "sticky.items.listItems") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + const createTool = findTool(tools, "sticky.items.createItem") as + | { annotations?: { requiresApproval?: boolean } } + | undefined; + + // Override still active: GET requires approval, POST does not. + expect(listTool?.annotations?.requiresApproval).toBe(true); + expect(createTool?.annotations?.requiresApproval).toBeFalsy(); + + // And the name actually changed — sanity check that updateSource ran. + const source = yield* executor.openapi.getSource("sticky", TEST_SCOPE); + expect(source?.name).toBe("Renamed"); + }), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 56f72190c..837a38298 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -54,6 +54,7 @@ import { type StoredSource, } from "./store"; import { + AnnotationPolicy, HeaderValue as HeaderValueSchema, InvocationConfig, OAuth2Auth, @@ -82,12 +83,18 @@ export interface OpenApiSpecConfig { readonly namespace?: string; readonly headers?: Record; readonly oauth2?: OAuth2Auth; + /** Per-source override for the default HTTP-method-based annotation + * policy. Omit to use the default (POST/PUT/PATCH/DELETE require + * approval). */ + readonly annotationPolicy?: AnnotationPolicy; } export interface OpenApiUpdateSourceInput { readonly name?: string; readonly baseUrl?: string; readonly headers?: Record; + /** `null` clears a previously-set override; `undefined` leaves as-is. */ + readonly annotationPolicy?: AnnotationPolicy | null; } // --------------------------------------------------------------------------- @@ -287,6 +294,7 @@ export const openApiPlugin = definePlugin( readonly namespace?: string; readonly headers?: Record; readonly oauth2?: OAuth2Auth; + readonly annotationPolicy?: AnnotationPolicy; }; // ctx comes from the plugin runtime — the same instance is passed to @@ -332,6 +340,7 @@ export const openApiPlugin = definePlugin( namespace: input.namespace, headers: input.headers, oauth2, + annotationPolicy: input.annotationPolicy, }; const storedSource: StoredSource = { @@ -464,6 +473,7 @@ export const openApiPlugin = definePlugin( namespace: config.namespace, headers: config.headers, oauth2: config.oauth2, + annotationPolicy: config.annotationPolicy, }); }); @@ -504,6 +514,7 @@ export const openApiPlugin = definePlugin( name: input.name?.trim() || undefined, baseUrl: input.baseUrl, headers: input.headers, + annotationPolicy: input.annotationPolicy, }), startOAuth: (input) => @@ -851,18 +862,29 @@ export const openApiPlugin = definePlugin( scopes.add(row.scope_id as string); } const byScope = new Map>(); + const policyByScope = new Map< + string, + { readonly requireApprovalFor?: readonly string[] } | null | undefined + >(); for (const scope of scopes) { const ops = yield* ctx.storage.listOperationsBySource(sourceId, scope); const byId = new Map(); for (const op of ops) byId.set(op.toolId, op.binding); byScope.set(scope, byId); + const source = yield* ctx.storage.getSource(sourceId, scope); + policyByScope.set(scope, source?.config.annotationPolicy ?? null); } const out: Record = {}; for (const row of toolRows as readonly ToolRow[]) { - const binding = byScope.get(row.scope_id as string)?.get(row.id); + const scope = row.scope_id as string; + const binding = byScope.get(scope)?.get(row.id); if (binding) { - out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate); + out[row.id] = annotationsForOperation( + binding.method, + binding.pathTemplate, + policyByScope.get(scope) ?? null, + ); } } return out; diff --git a/packages/plugins/openapi/src/sdk/store.ts b/packages/plugins/openapi/src/sdk/store.ts index 3d749f2d1..3772b1295 100644 --- a/packages/plugins/openapi/src/sdk/store.ts +++ b/packages/plugins/openapi/src/sdk/store.ts @@ -110,6 +110,7 @@ export class StoredSourceSchema extends Schema.Class( // sources onboarded with an OAuth2 preset — one OAuth2Auth per // securitySchemeName, with the secret ids that back its tokens. oauth2: Schema.optional(OAuth2Auth), + annotationPolicy: Schema.optional(AnnotationPolicy), }), // TODO(migration): make required once all rows have been migrated to // carry invocationConfig. Left optional for decode compat with rows @@ -139,6 +140,9 @@ const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig); const encodeOAuth2 = Schema.encodeSync(OAuth2Auth); const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth); +const encodeAnnotationPolicy = Schema.encodeSync(AnnotationPolicy); +const decodeAnnotationPolicy = Schema.decodeUnknownSync(AnnotationPolicy); + const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession); const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession); @@ -188,6 +192,7 @@ export interface OpenapiStore { readonly baseUrl?: string; readonly headers?: Record; readonly oauth2?: OAuth2Auth; + readonly annotationPolicy?: AnnotationPolicy | null; }, ) => Effect.Effect; @@ -238,6 +243,13 @@ export const makeDefaultOpenapiStore = ({ oauth2Raw == null ? undefined : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw); + const policyRaw = row.annotation_policy; + const annotationPolicy = + policyRaw == null + ? undefined + : decodeAnnotationPolicy( + typeof policyRaw === "string" ? JSON.parse(policyRaw) : policyRaw, + ); const headers = decodeHeaders(row.headers); const invocationConfig = decodeInvocationConfig( asJsonObject(row.invocation_config), @@ -252,6 +264,7 @@ export const makeDefaultOpenapiStore = ({ baseUrl: (row.base_url as string | null | undefined) ?? undefined, headers, oauth2, + annotationPolicy, }, invocationConfig, }; @@ -300,6 +313,12 @@ export const makeDefaultOpenapiStore = ({ oauth2: input.config.oauth2 ? (encodeOAuth2(input.config.oauth2) as unknown as Record) : undefined, + annotation_policy: input.config.annotationPolicy + ? (encodeAnnotationPolicy(input.config.annotationPolicy) as unknown as Record< + string, + unknown + >) + : undefined, invocation_config: encodeInvocationConfig( input.invocationConfig, ) as unknown as Record, @@ -339,6 +358,10 @@ export const makeDefaultOpenapiStore = ({ patch.headers !== undefined ? patch.headers : existing.config.headers ?? {}; const nextOAuth2 = patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2; + const nextAnnotationPolicy = + patch.annotationPolicy !== undefined + ? patch.annotationPolicy ?? undefined + : existing.config.annotationPolicy; const nextInvocationConfig = new InvocationConfig({ baseUrl: nextBaseUrl ?? existing.invocationConfig.baseUrl, @@ -346,6 +369,11 @@ export const makeDefaultOpenapiStore = ({ oauth2: nextOAuth2 ? Option.some(nextOAuth2) : Option.none(), }); + // `null` explicitly clears an optional JSON column — the + // transformer pipeline drops `undefined` update values (treating + // them as "leave the existing column alone"), so a clear-override + // request (`patch.annotationPolicy === null`) must surface as + // `null` in the update payload, not `undefined`. yield* adapter.update({ model: "openapi_source", where: [ @@ -359,6 +387,14 @@ export const makeDefaultOpenapiStore = ({ oauth2: nextOAuth2 ? (encodeOAuth2(nextOAuth2) as unknown as Record) : undefined, + annotation_policy: nextAnnotationPolicy + ? (encodeAnnotationPolicy(nextAnnotationPolicy) as unknown as Record< + string, + unknown + >) + : patch.annotationPolicy === null + ? null + : undefined, invocation_config: encodeInvocationConfig( nextInvocationConfig, ) as unknown as Record, diff --git a/packages/react/src/plugins/approval-policy-field.tsx b/packages/react/src/plugins/approval-policy-field.tsx new file mode 100644 index 000000000..c18bb1e87 --- /dev/null +++ b/packages/react/src/plugins/approval-policy-field.tsx @@ -0,0 +1,330 @@ +import * as React from "react"; +import { ShieldCheckIcon, RotateCcwIcon } from "lucide-react"; + +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "../components/card-stack"; +import { Button } from "../components/button"; +import { FieldLabel } from "../components/field"; +import { Switch } from "../components/switch"; +import { cn } from "../lib/utils"; + +// --------------------------------------------------------------------------- +// Shared UX primitive — lets each source plugin render a per-source override +// for which tool invocations require approval before running. +// +// The component is presentational only: it takes a typed list of togglable +// tokens (HTTP methods for OpenAPI/Google Discovery, operation kinds for +// GraphQL, anything for future plugins) plus a per-token "default approval +// required" hint, and stores the user's explicit list in a local set. Pass +// `value === undefined` to render "using defaults"; the first interaction +// materializes a concrete list and emits it via onChange. +// +// The switch variant renders a single toggle for plugins whose policy is a +// simple on/off override (e.g. MCP, which defaults to no tool-level approval +// because servers handle elicitation mid-invocation). +// --------------------------------------------------------------------------- + +export interface ApprovalPolicyToken { + readonly value: string; + readonly label: React.ReactNode; + /** Short description shown under the label when rendered in "detail" mode. */ + readonly description?: React.ReactNode; + /** Whether this token requires approval in the plugin's DEFAULT policy. */ + readonly defaultRequiresApproval: boolean; + /** Visual tone. `safe` → green tint; `write` → amber/red tint; `neutral` → muted. */ + readonly tone?: "safe" | "write" | "neutral"; +} + +// Shared shell — title, description, reset button, children. +interface PolicyShellProps { + readonly title?: React.ReactNode; + readonly description?: React.ReactNode; + readonly isOverridden: boolean; + readonly onReset: () => void; + readonly children: React.ReactNode; +} + +function PolicyShell({ + title = "Approval policy", + description, + isOverridden, + onReset, + children, +}: PolicyShellProps) { + return ( +
+
+ + + {title} + + {isOverridden && ( + + )} +
+ + +
+ {description && ( +

{description}

+ )} + {children} + {!isOverridden && ( +

+ Using plugin defaults. Click any option to start overriding. +

+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Toggles variant — one togglable chip per token. OpenAPI, Google Discovery, +// GraphQL all use this. +// --------------------------------------------------------------------------- + +export interface ApprovalPolicyTogglesProps { + readonly title?: React.ReactNode; + readonly description?: React.ReactNode; + readonly tokens: readonly ApprovalPolicyToken[]; + /** + * Current explicit override — the set of token `value`s that require + * approval. `undefined` means "use defaults" (the plugin's derived + * behaviour). When `null` is emitted, the override has been cleared. + */ + readonly value: readonly string[] | undefined; + readonly onChange: (next: readonly string[] | undefined) => void; + /** + * Optional layout. `grid` (default) wraps chips in a responsive grid; + * `list` stacks them vertically with room for descriptions. + */ + readonly layout?: "grid" | "list"; +} + +const toneClasses: Record<"safe" | "write" | "neutral", string> = { + safe: + "data-[active=true]:bg-emerald-500/15 data-[active=true]:text-emerald-700 data-[active=true]:border-emerald-500/40 data-[active=true]:ring-emerald-500/20 dark:data-[active=true]:text-emerald-300", + write: + "data-[active=true]:bg-amber-500/15 data-[active=true]:text-amber-800 data-[active=true]:border-amber-500/50 data-[active=true]:ring-amber-500/20 dark:data-[active=true]:text-amber-200", + neutral: + "data-[active=true]:bg-primary/10 data-[active=true]:text-foreground data-[active=true]:border-primary/40 data-[active=true]:ring-primary/20", +}; + +export function ApprovalPolicyToggles({ + title, + description, + tokens, + value, + onChange, + layout = "grid", +}: ApprovalPolicyTogglesProps) { + // `value === undefined` means "using defaults". Derive the effective + // selection set from defaults in that case so we can render chip state. + const effective = React.useMemo(() => { + if (value !== undefined) { + return new Set(value.map((v) => v.toLowerCase())); + } + return new Set( + tokens.filter((t) => t.defaultRequiresApproval).map((t) => t.value.toLowerCase()), + ); + }, [value, tokens]); + + const isOverridden = value !== undefined; + + const toggle = (tokenValue: string) => { + const next = new Set(effective); + const key = tokenValue.toLowerCase(); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + // Preserve the canonical (original-cased) token values in the emitted + // array so the backend receives the same literal strings the caller + // declared. + const canonical = tokens + .filter((t) => next.has(t.value.toLowerCase())) + .map((t) => t.value); + onChange(canonical); + }; + + return ( + onChange(undefined)} + > +
+ {tokens.map((token) => { + const active = effective.has(token.value.toLowerCase()); + const tone = token.tone ?? "neutral"; + return ( + + ); + })} +
+

+ Highlighted options require approval before a tool call runs. Click to toggle. +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Switch variant — single on/off override. MCP uses this. +// --------------------------------------------------------------------------- + +export interface ApprovalPolicySwitchProps { + readonly title?: React.ReactNode; + readonly description?: React.ReactNode; + readonly switchLabel: React.ReactNode; + readonly switchDescription?: React.ReactNode; + readonly defaultValue: boolean; + /** + * Current explicit override. `undefined` means "use defaults"; a boolean + * means the user has pinned the override. + */ + readonly value: boolean | undefined; + readonly onChange: (next: boolean | undefined) => void; +} + +export function ApprovalPolicySwitch({ + title, + description, + switchLabel, + switchDescription, + defaultValue, + value, + onChange, +}: ApprovalPolicySwitchProps) { + const effective = value ?? defaultValue; + const isOverridden = value !== undefined; + return ( + onChange(undefined)} + > +
+
+ {switchLabel} + {switchDescription && ( + {switchDescription} + )} +
+ { + // Allow toggling back to "default" by matching the default value. + if (checked === defaultValue) { + onChange(undefined); + } else { + onChange(checked); + } + }} + /> +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Token presets — keep the shape of "default requires approval" out of each +// plugin's UI by exporting canonical lists. +// --------------------------------------------------------------------------- + +export const HTTP_METHOD_TOKENS: readonly ApprovalPolicyToken[] = [ + { value: "GET", label: "GET", tone: "safe", defaultRequiresApproval: false }, + { value: "HEAD", label: "HEAD", tone: "safe", defaultRequiresApproval: false }, + { value: "OPTIONS", label: "OPTIONS", tone: "safe", defaultRequiresApproval: false }, + { value: "POST", label: "POST", tone: "write", defaultRequiresApproval: true }, + { value: "PUT", label: "PUT", tone: "write", defaultRequiresApproval: true }, + { value: "PATCH", label: "PATCH", tone: "write", defaultRequiresApproval: true }, + { value: "DELETE", label: "DELETE", tone: "write", defaultRequiresApproval: true }, +]; + +export const GRAPHQL_OPERATION_TOKENS: readonly ApprovalPolicyToken[] = [ + { + value: "query", + label: "query", + tone: "safe", + defaultRequiresApproval: false, + description: "Read-only operations", + }, + { + value: "mutation", + label: "mutation", + tone: "write", + defaultRequiresApproval: true, + description: "Operations that change server state", + }, +];