feat(core): add provider policy enforcement
This commit is contained in:
parent
7a7075d86f
commit
103f764624
12 changed files with 310 additions and 33 deletions
|
|
@ -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({
|
||||
|
|
|
|||
95
packages/core/test/config/policy.test.ts
Normal file
95
packages/core/test/config/policy.test.ts
Normal 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")
|
||||
})
|
||||
|
|
@ -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)),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue