feat(core): add provider policy enforcement

This commit is contained in:
Dax Raad 2026-07-16 14:27:31 -04:00
commit 103f764624
12 changed files with 310 additions and 33 deletions

View file

@ -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({

View file

@ -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<boolean>) {
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")
})

View file

@ -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)),
),
)