diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c195a56b71..ec3037193c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2,8 +2,9 @@ export * as Config from "./config" import { makeLocationNode } from "./effect/app-node" import path from "path" +import { isDeepStrictEqual } from "node:util" import { type ParseError, parse } from "jsonc-parser" -import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Integration } from "@opencode-ai/schema/integration" @@ -165,6 +166,7 @@ const layer = Layer.effect( const credentials = yield* Credential.Service const wellknown = yield* WellKnown.Service const names = ["opencode.json", "opencode.jsonc"] + const reloadLock = Semaphore.makeUnsafe(1) const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) @@ -326,12 +328,17 @@ const layer = Layer.effect( } }) - const reload = Effect.fn("Config.reload")(function* () { - const next = yield* discover() - configs = next - yield* reconcile(next) - yield* events.publish(ConfigSchema.Event.Updated, {}) - }) + const reload = Effect.fn("Config.reload")(() => + reloadLock.withPermit( + Effect.gen(function* () { + const next = yield* discover() + if (isDeepStrictEqual(configs, next)) return + configs = next + yield* reconcile(next) + yield* events.publish(ConfigSchema.Event.Updated, {}) + }), + ), + ) yield* Stream.fromPubSub(updates).pipe( Stream.debounce("100 millis"), @@ -354,7 +361,7 @@ const layer = Layer.effect( ), Effect.forkScoped({ startImmediately: true }), ) - yield* wellknown.changes.pipe( + yield* events.subscribe(WellKnown.Event.Updated).pipe( Stream.runForEach(() => reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))), ), diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts index a89fbfe97f..d8ecbbb762 100644 --- a/packages/core/src/config/experimental.ts +++ b/packages/core/src/config/experimental.ts @@ -2,9 +2,13 @@ export * as ConfigExperimental from "./experimental" import { Schema } from "effect" import { NonNegativeInt } from "../schema" +import { ConfigPolicy } from "./policy" export class Info extends Schema.Class("ConfigExperimental.Info")({ subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({ description: "Maximum subagent nesting depth. Defaults to 1.", }), + policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({ + description: "Ordered policies controlling access to configured resources", + }), }) {} diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts new file mode 100644 index 0000000000..fd05939d17 --- /dev/null +++ b/packages/core/src/config/plugin/policy.ts @@ -0,0 +1,35 @@ +export * as ConfigPolicyPlugin from "./policy" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Stream } from "effect" +import { Config } from "../../config" +import { Wildcard } from "../../util/wildcard" + +export const Plugin = define({ + id: "opencode.config.policy", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const loaded = { entries: yield* config.entries() } + yield* ctx.catalog.transform((catalog) => { + // User-global policy takes priority over policy authored by a repository. + const policies = loaded.entries + .filter((entry): entry is Config.Document => entry.type === "document") + .toReversed() + .flatMap((entry) => entry.info.experimental?.policies ?? []) + for (const record of catalog.provider.list()) { + const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource)) + if (policy?.effect === "deny") catalog.provider.remove(record.provider.id) + } + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.catalog.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) diff --git a/packages/core/src/config/policy.ts b/packages/core/src/config/policy.ts new file mode 100644 index 0000000000..127bc822a3 --- /dev/null +++ b/packages/core/src/config/policy.ts @@ -0,0 +1,13 @@ +export * as ConfigPolicy from "./policy" + +import { Schema } from "effect" + +export const Effect = Schema.Literals(["allow", "deny"]) +export type Effect = typeof Effect.Type + +export const Info = Schema.Struct({ + action: Schema.Literal("provider.use"), + resource: Schema.String, + effect: Effect, +}) +export type Info = typeof Info.Type diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 634ad46bb8..69725e2c24 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -10,6 +10,7 @@ import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigProviderPlugin } from "../config/plugin/provider" +import { ConfigPolicyPlugin } from "../config/plugin/policy" import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" import { EventV2 } from "../event" @@ -151,6 +152,7 @@ const post = [ ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, VariantPlugin.Plugin, + ConfigPolicyPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] export const list = Effect.fn("PluginInternal.list")(function* () { diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index ed9e345508..63348e5480 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -77,10 +77,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { commands: commands(info.command), instructions: info.instructions, references: info.references ?? info.reference, - experimental: - info.experimental?.subagent_depth === undefined - ? undefined - : { subagent_depth: info.experimental.subagent_depth }, + experimental: experimental(info), plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, ), @@ -88,6 +85,31 @@ export function migrate(info: typeof ConfigV1.Info.Type) { } } +function experimental(info: typeof ConfigV1.Info.Type) { + const policies = [ + ...(info.enabled_providers === undefined + ? [] + : [ + { action: "provider.use" as const, resource: "*", effect: "deny" as const }, + ...info.enabled_providers.map((resource) => ({ + action: "provider.use" as const, + resource, + effect: "allow" as const, + })), + ]), + ...(info.disabled_providers ?? []).map((resource) => ({ + action: "provider.use" as const, + resource, + effect: "deny" as const, + })), + ] + if (info.experimental?.subagent_depth === undefined && !policies.length) return + return { + subagent_depth: info.experimental?.subagent_depth, + policies: policies.length ? policies : undefined, + } +} + function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly>) { const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries( tools ?? {}, diff --git a/packages/core/src/wellknown.ts b/packages/core/src/wellknown.ts index b259c4a953..edc00475f6 100644 --- a/packages/core/src/wellknown.ts +++ b/packages/core/src/wellknown.ts @@ -1,10 +1,12 @@ export * as WellKnown from "./wellknown" import { Integration } from "@opencode-ai/schema/integration" -import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from "effect" +import { Context, Effect, Layer, Ref, Schema, Semaphore } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { isDeepStrictEqual } from "node:util" import { makeGlobalNode } from "./effect/app-node" import { httpClient } from "./effect/app-node-platform" +import { EventV2 } from "./event" import { KV } from "./kv" export interface Auth extends Schema.Schema.Type {} @@ -43,14 +45,18 @@ export interface Entry { export interface Interface { readonly entries: () => Effect.Effect readonly snapshot: () => readonly Entry[] + readonly refresh: () => Effect.Effect readonly add: (origin: string) => Effect.Effect readonly remove: (origin: string) => Effect.Effect - readonly changes: Stream.Stream readonly resolve: (entry: Entry, variables: Readonly>) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/WellKnown") {} +export const Event = { + Updated: EventV2.ephemeral({ type: "wellknown.updated", schema: {} }), +} + export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) { const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode` const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) @@ -97,8 +103,8 @@ const layer = Layer.effect( Effect.gen(function* () { const http = yield* HttpClient.HttpClient const kv = yield* KV.Service + const events = yield* EventV2.Service const cache = yield* Ref.make(new Map()) - const changes = yield* PubSub.unbounded() const lock = Semaphore.makeUnsafe(1) const load = Effect.fn("WellKnown.load")(function* () { @@ -117,9 +123,41 @@ const layer = Layer.effect( return entries }) + const refresh = Effect.fn("WellKnown.refresh")(function* () { + return yield* lock.withPermit( + Effect.gen(function* () { + const value = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(value) ? value : [] + if (!origins.length) return false + const entries = yield* Effect.forEach(origins, (origin) => + inspect(origin).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })), + ), + ) + const next = new Map(entries.map((entry) => [entry.origin, entry])) + const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next) + if (changed) yield* Ref.set(cache, next) + yield* events.publish(Event.Updated, {}) + return changed + }), + ) + }) + + yield* Effect.sleep("10 minutes").pipe( + Effect.andThen( + refresh().pipe( + Effect.catch((error) => Effect.logWarning("failed to refresh wellknown manifests", { error })), + ), + ), + Effect.forever, + Effect.forkScoped({ startImmediately: true }), + ) + return Service.of({ entries: load, snapshot: () => Array.from(Ref.getUnsafe(cache).values()), + refresh, add: Effect.fn("WellKnown.add")(function* (value) { return yield* lock.withPermit( Effect.gen(function* () { @@ -131,7 +169,7 @@ const layer = Layer.effect( const origins = Schema.is(Sources)(sources) ? sources : [] yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin]))) yield* Ref.update(cache, (current) => new Map(current).set(origin, entry)) - yield* PubSub.publish(changes, undefined) + yield* events.publish(Event.Updated, {}) return entry }), ) @@ -151,11 +189,10 @@ const layer = Layer.effect( next.delete(origin) return next }) - yield* PubSub.publish(changes, undefined) + yield* events.publish(Event.Updated, {}) }), ) }), - changes: Stream.fromPubSub(changes), resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) { return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)) }), @@ -163,4 +200,4 @@ const layer = Layer.effect( }), ) -export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node] }) +export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node, EventV2.node] }) diff --git a/packages/core/src/wellknown/plugin.ts b/packages/core/src/wellknown/plugin.ts index e9f4f13576..9be129a248 100644 --- a/packages/core/src/wellknown/plugin.ts +++ b/packages/core/src/wellknown/plugin.ts @@ -2,11 +2,13 @@ export * as WellKnownPlugin from "./plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Stream } from "effect" +import { EventV2 } from "../event" import { WellKnown } from "../wellknown" export const Plugin = define({ id: "opencode.wellknown", effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service const wellknown = yield* WellKnown.Service yield* wellknown.entries().pipe(Effect.orDie) yield* ctx.integration.transform((draft) => { @@ -26,7 +28,7 @@ export const Plugin = define({ }) }) }) - yield* wellknown.changes.pipe( + yield* events.subscribe(WellKnown.Event.Updated).pipe( Stream.runForEach(() => ctx.integration.reload()), Effect.forkScoped({ startImmediately: true }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index e0ddb13d16..d92a6a722c 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -53,9 +53,9 @@ const emptyWellknownNode = makeGlobalNode({ WellKnown.Service.of({ entries: () => Effect.succeed([]), snapshot: () => [], + refresh: () => Effect.succeed(false), add: () => Effect.die("unused Wellknown.add"), remove: () => Effect.die("unused Wellknown.remove"), - changes: Stream.empty, resolve: () => Effect.die("unused Wellknown.resolve"), }), ), @@ -216,9 +216,9 @@ describe("Config", () => { WellKnown.Service.of({ entries: () => Effect.succeed([entry]), snapshot: () => [entry], + refresh: () => Effect.succeed(false), add: () => Effect.die("unused Wellknown.add"), remove: () => Effect.die("unused Wellknown.remove"), - changes: Stream.empty, resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]), }), ), @@ -289,6 +289,25 @@ describe("Config", () => { }), ) + it.effect("migrates v1 provider lists to policies", () => + Effect.sync(() => { + expect( + ConfigMigrateV1.migrate({ + enabled_providers: ["anthropic", "openai"], + disabled_providers: ["openai"], + }).experimental?.policies, + ).toEqual([ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "anthropic", effect: "allow" }, + { action: "provider.use", resource: "openai", effect: "allow" }, + { action: "provider.use", resource: "openai", effect: "deny" }, + ]) + expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([ + { action: "provider.use", resource: "*", effect: "deny" }, + ]) + }), + ) + it.effect("migrates v1 provider setup options into AISDK settings", () => Effect.sync(() => { const migrated = ConfigMigrateV1.migrate({ diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts new file mode 100644 index 0000000000..810f20ffd9 --- /dev/null +++ b/packages/core/test/config/policy.test.ts @@ -0,0 +1,95 @@ +import { describe, expect } from "bun:test" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" +import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy" +import { EventV2 } from "@opencode-ai/core/event" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Effect, Schema } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) => + new Config.Document({ + type: "document", + info: decode({ + experimental: { + policies: items.map((item) => ({ action: "provider.use", ...item })), + }, + }), + }) + +const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* ConfigPolicyPlugin.Plugin.effect(host).pipe( + Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })), + ) +}) + +describe("ConfigPolicyPlugin.Plugin", () => { + it.effect("filters plugin-provided providers with ordered wildcard policies", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openai, () => {}) + catalog.provider.update(ProviderV2.ID.anthropic, () => {}) + catalog.provider.update(ProviderV2.ID.make("company-internal"), () => {}) + }) + yield* addPlugin(() => [ + policies( + { effect: "deny", resource: "*" }, + { effect: "allow", resource: "anthropic" }, + { effect: "allow", resource: "company-*" }, + ), + ]) + + expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined() + expect(yield* catalog.provider.get(ProviderV2.ID.anthropic)).toBeDefined() + expect(yield* catalog.provider.get(ProviderV2.ID.make("company-internal"))).toBeDefined() + }), + ) + + it.effect("prevents project policy from overriding user-global policy", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin(() => [ + policies({ effect: "deny", resource: "openai" }), + policies({ effect: "allow", resource: "openai" }), + ]) + + expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined() + }), + ) + + it.live("reloads changed policies", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })] + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin(() => entries) + expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined() + + entries = [policies({ effect: "allow", resource: "openai" })] + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil( + catalog.provider.get(ProviderV2.ID.openai).pipe(Effect.map((provider) => provider !== undefined)), + ) + }), + ) +}) + +const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { + for (let attempt = 0; attempt < 200; attempt++) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.die("Timed out waiting for policy reload") +}) diff --git a/packages/core/test/wellknown.test.ts b/packages/core/test/wellknown.test.ts index 57ded34cdf..966bf21e76 100644 --- a/packages/core/test/wellknown.test.ts +++ b/packages/core/test/wellknown.test.ts @@ -3,11 +3,12 @@ import { Effect, Fiber, Stream } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { KV } from "@opencode-ai/core/kv" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" import { WellKnown } from "@opencode-ai/core/wellknown" import { testEffect } from "./lib/effect" const it = testEffect(FetchHttpClient.layer) -const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node]))) +const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node, EventV2.node]))) it.live("loads embedded and remote configuration", () => Effect.acquireUseRelease( @@ -65,7 +66,10 @@ serviceIt.live("persists sources in one KV value", () => Effect.gen(function* () { const wellknown = yield* WellKnown.Service const kv = yield* KV.Service - const changed = yield* wellknown.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const events = yield* EventV2.Service + const changed = yield* events + .subscribe(WellKnown.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) const entry = yield* wellknown.add(`${server.url.origin}/`) expect(entry.origin).toBe(server.url.origin) @@ -80,3 +84,40 @@ serviceIt.live("persists sources in one KV value", () => (server) => Effect.promise(() => server.stop(true)), ), ) + +serviceIt.live("refreshes changed manifests", () => + Effect.acquireUseRelease( + Effect.sync(() => { + let command = "first" + return { + server: Bun.serve({ + port: 0, + fetch: () => Response.json({ auth: { command: [command], env: "TOKEN" } }), + }), + update: () => { + command = "second" + }, + } + }), + ({ server, update }) => + Effect.gen(function* () { + const wellknown = yield* WellKnown.Service + const events = yield* EventV2.Service + yield* wellknown.add(server.url.origin) + const refreshed = yield* events + .subscribe(WellKnown.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + expect(yield* wellknown.refresh()).toBe(false) + expect(yield* Fiber.join(refreshed)).toHaveLength(1) + + const changed = yield* events + .subscribe(WellKnown.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + update() + expect(yield* wellknown.refresh()).toBe(true) + expect(yield* Fiber.join(changed)).toHaveLength(1) + expect(wellknown.snapshot()[0]?.manifest.auth?.command).toEqual(["second"]) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), +) diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md index dc5c1ef418..aa14bf35a3 100644 --- a/specs/v2/provider-policy.md +++ b/specs/v2/provider-policy.md @@ -1,10 +1,10 @@ -# Provider Policy Proposal +# Provider Policy -Status: **Proposed and unimplemented.** This document is design input, not current configuration or runtime behavior. +Status: **Implemented.** ## Purpose -This proposal would let policies control whether an operation on a named resource is allowed. Statements could be authored in configuration files while policy evaluation remained its own runtime concern. +Policies control whether an operation on a named resource is allowed. Statements are authored in configuration files and applied by a terminal catalog plugin. The first policy consumer is provider availability: @@ -60,7 +60,7 @@ interface PolicyInfo { } ``` -The proposed `Policy` module would own the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains would define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` would fix `action` to `"provider.use"`. The config schema could gather those domain-defined statement schemas into an `experimental.policies` union. +`ConfigPolicy` owns the statement schema. The policy plugin interprets the supported `provider.use` action after all other catalog transforms have run. ## Matching @@ -236,16 +236,16 @@ Provider policy applies regardless of how a provider becomes known or usable, in ## Applying Provider Policy -Provider records and model overrides should be assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered. +Provider records and model overrides are assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered. -Intended flow: +Flow: 1. Build provider/model catalog entries. 2. Apply configured provider and model overrides. -3. Ask `Policy.Service` to evaluate `provider.use` for each provider ID. -4. Prevent denied providers from being selectable or used. +3. Run the terminal config policy transform. +4. Remove providers denied by the final matching `provider.use` statement. -Whether denied providers are removed entirely or retained as disabled records for diagnostics remains an implementation decision. +Config reload refreshes the plugin's policy snapshot and rebuilds the catalog. ## Legacy Migration