feat(core): add provider policy enforcement

This commit is contained in:
Dax Raad 2026-05-27 22:07:26 -04:00
commit e24b589da1
22 changed files with 946 additions and 22 deletions

View file

@ -7,6 +7,7 @@ import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
export type ProviderRecord = {
provider: ProviderV2.Info
@ -25,6 +26,8 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
modelID: ModelV2.ID,
}) {}
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
@ -84,6 +87,7 @@ export const layer = Layer.effect(
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => {
@ -199,16 +203,23 @@ export const layer = Layer.effect(
return result
}
const transform = Effect.fn("CatalogV2.transform")(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
const applyPolicy = Effect.fn("CatalogV2.applyPolicy")(function* (draft: {
records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
data: ProviderRecord[]
}) {
const ctx = context(draft)
for (const record of [...draft.data]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
ctx.provider.remove(record.provider.id)
}
}
})
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
for (const loader of loaders) loader.update(context(draft))
yield* plugin.trigger("catalog.transform", context(draft), {})
yield* applyPolicy(draft)
records = draft.records
})
@ -217,6 +228,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
yield* applyPolicy(draft)
records = draft.records
}),
),
@ -354,4 +366,7 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
)

View file

@ -6,6 +6,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
import { AppFileSystem } from "../filesystem"
import { Global } from "../global"
import { Location } from "../location"
import { Policy } from "../policy"
import { AbsolutePath } from "../schema"
import { ConfigV2 } from "./schema"
@ -24,6 +25,7 @@ export const layer = Layer.effect(
const fs = yield* AppFileSystem.Service
const global = yield* Global.Service
const location = yield* Location.Service
const policy = yield* Policy.Service
const names = ["config.json", "opencode.json", "opencode.jsonc"]
const loadFile = Effect.fnUntraced(function* (filepath: string) {
@ -34,7 +36,11 @@ export const layer = Layer.effect(
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
const info = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all" }))
// Accept legacy fields while v2 is migrated incrementally; recognized
// fields still have to satisfy the v2 schema.
const info = Option.getOrUndefined(
Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all", onExcessProperty: "ignore" }),
)
if (!info) return
return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info })
})
@ -74,6 +80,9 @@ export const layer = Layer.effect(
// Apply general settings first and more specific settings last:
// global config, project files, then `.opencode` files.
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
// Rules use the opposite order so a user-global rule can override a
// repository rule. Statement order inside each file stays unchanged.
yield* policy.load(configs.toReversed().flatMap((config) => config.info.policies ?? []))
return Service.of({
directories: Effect.fn("Config.directories")(function* () {
@ -86,4 +95,7 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
)

View file

@ -1,12 +1,27 @@
export * as ConfigV2 from "./schema"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy as PolicyV2 } from "../policy"
import { ConfigProvider } from "./provider"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
export class Policy extends Schema.Class<Policy>("ConfigV2.Policy")({
...PolicyV2.Info.fields,
action: PolicyAction,
}) {}
export class Info extends Schema.Class<Info>("ConfigV2.Info")({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
policies: Policy.pipe(Schema.Array, Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}

View file

@ -2,14 +2,17 @@ import { Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
import { Policy } from "./policy"
import { Config } from "./config/config"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe(
Layer.provideMerge(Policy.defaultLayer),
Layer.provideMerge(Location.defaultLayer(ref)),
)
return result
},
idleTimeToLive: "5 minutes",
idleTimeToLive: "60 minutes",
dependencies: [],
}) {}

View file

@ -0,0 +1,44 @@
export * as Policy from "./policy"
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export type Effect = typeof Effect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: Effect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
export const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
let statements: Info[] = []
yield* Location.Service
return Service.of({
load: EffectRuntime.fn("Policy.load")(function* (input) {
statements = input
}),
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}),
})
}),
)
export const defaultLayer = layer

View file

@ -5,6 +5,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
@ -18,6 +19,7 @@ const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(Policy.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
@ -242,4 +244,23 @@ describe("CatalogV2", () => {
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}),
)
it.effect("removes providers denied by policy after loading", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policy = yield* Policy.Service
const providerID = ProviderV2.ID.make("blocked")
const load = yield* catalog.loader()
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
yield* load((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none())
}),
)
})

View file

@ -8,6 +8,7 @@ import { ConfigV2 } from "@opencode-ai/core/config/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@ -25,6 +26,7 @@ function testLayer(
return Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.layerWith({ config: globalDirectory })),
Layer.provideMerge(Policy.defaultLayer),
Layer.provide(
Layer.succeed(
Location.Service,
@ -120,6 +122,70 @@ describe("Config", () => {
),
)
it.live("accepts $schema metadata without writing it into config files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "opencode.json")
const contents = JSON.stringify({
shell: "/bin/zsh",
policies: [{ effect: "deny", action: "provider.use", resource: "openai" }],
providers: { local: provider },
})
yield* Effect.promise(() => fs.writeFile(file, contents))
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.policies?.[0]).toEqual({
effect: "deny",
action: "provider.use",
resource: "openai",
})
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("loads recognized v2 fields from config files that still contain legacy fields", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
shell: "/bin/bash",
model: "anthropic/claude",
disabled_providers: ["openai"],
server: { port: 4096 },
}),
),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
expect(documents).toHaveLength(1)
expect(documents[0]?.info.shell).toBe("/bin/bash")
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("ignores invalid files while loading valid config values", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@ -145,6 +211,36 @@ describe("Config", () => {
),
)
it.live("loads policy statements in reverse config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({ policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }),
)
await fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] }),
)
})
return yield* Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}).pipe(Effect.provide(testLayer(tmp.path, global)))
})
}),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),

View file

@ -5,6 +5,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -18,6 +19,7 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),

View file

@ -6,6 +6,7 @@ import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -19,6 +20,7 @@ const itWithAccount = testEffect(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),

View file

@ -5,11 +5,11 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, withEnv } from "./provider-helper"
@ -29,14 +29,15 @@ void mock.module("gitlab-ai-provider", () => ({
}))
const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
Layer.mergeAll(
Catalog.defaultLayer,
PluginV2.defaultLayer,
AccountV2.defaultLayer,
EventV2.defaultLayer,
npmLayer,
).pipe(
Layer.provide(Policy.defaultLayer),
Layer.provide(Location.defaultLayer({ directory: AbsolutePath.make("/") })),
),
)

View file

@ -7,6 +7,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@ -51,6 +52,7 @@ export const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(Policy.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),
),

View file

@ -5,6 +5,7 @@ import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@ -225,6 +226,8 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
}).pipe(
Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(Policy.defaultLayer), Layer.provide(locationLayer))),
),
)
})

View file

@ -0,0 +1,83 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
Policy.defaultLayer.pipe(
Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
),
)
describe("Policy", () => {
it.effect("returns the caller's fallback when no statement matches", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
}),
)
it.effect("evaluates wildcard provider rules in written order", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "*",
}),
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "anthropic",
}),
])
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
it.effect("matches action and resource independently", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "company-*",
}),
])
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
}),
)
it.effect("uses the last matching loaded statement", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "openai",
}),
new Policy.Info({
effect: "deny",
action: "provider.use",
resource: "openai",
}),
])
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
})

View file

@ -43,6 +43,7 @@ import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ConfigV2 } from "@opencode-ai/core/config/schema"
const log = Log.create({ service: "config" })
@ -177,6 +178,9 @@ export const Info = Schema.Struct({
enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigV2.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
model: Schema.optional(ConfigModelID).annotate({
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
}),

View file

@ -29,6 +29,9 @@ import { ModelID, ProviderID } from "./schema"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderError } from "./error"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
const log = Log.create({ service: "provider" })
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
@ -1207,9 +1210,11 @@ export const layer = Layer.effect(
const plugin = yield* Plugin.Service
const modelsDevSvc = yield* ModelsDev.Service
const runtimeFlags = yield* RuntimeFlags.Service
const locations = yield* LocationServiceMap
const state = yield* InstanceState.make<State>(() =>
const state = yield* InstanceState.make<State>((ctx) =>
Effect.gen(function* () {
const policy = yield* Policy.Service
using _ = log.time("state")
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
@ -1480,7 +1485,10 @@ export const layer = Layer.effect(
for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id)
if (!isProviderAllowed(providerID)) {
if (
!isProviderAllowed(providerID) ||
(yield* policy.evaluate("provider.use", providerID, "allow")) === "deny"
) {
delete providers[providerID]
continue
}
@ -1537,7 +1545,7 @@ export const layer = Layer.effect(
modelLoaders,
varsLoaders,
}
}),
}).pipe(Effect.provide(locations.get({ directory: AbsolutePath.make(ctx.directory) }))),
)
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
@ -1873,6 +1881,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(LocationServiceMap.layer),
),
)

View file

@ -6,6 +6,7 @@ import { ModelsDev } from "@opencode-ai/core/models-dev"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture"
import { markPluginDependenciesReady } from "../fixture/plugin"
import { Auth } from "@/auth"
@ -63,6 +64,7 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.layer(flags)),
Layer.provide(LocationServiceMap.layer),
)
const list = Provider.use.list()
@ -100,6 +102,11 @@ const alphaProviderConfig = {
},
}
const denyAnthropicPolicyConfig = {
provider: {},
policies: [{ effect: "deny" as const, action: "provider.use" as const, resource: "anthropic" }],
}
it.instance("provider loaded from env variable", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
@ -131,6 +138,16 @@ it.instance(
{ config: { disabled_providers: ["anthropic"] } },
)
it.instance(
"policies deny provider use",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeUndefined()
}),
{ config: denyAnthropicPolicyConfig },
)
it.instance(
"enabled_providers restricts to only listed providers",
Effect.gen(function* () {

View file

@ -1,6 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
import { describe, expect, test } from "bun:test"
import { tool, type ModelMessage, type JSONValue } from "ai"
@ -276,6 +277,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(LocationServiceMap.layer),
)
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
const recordedClient = LLMClient.layer.pipe(

View file

@ -259,6 +259,7 @@ export default defineConfig({
"commands",
"formatters",
"permissions",
"policies",
"lsp",
"mcp-servers",
"acp",

View file

@ -393,6 +393,27 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo
---
### Policies
Use the `policies` option to allow or deny OpenCode actions on configured resources. Currently, policies can control which providers OpenCode may use.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "openai"
}
]
}
```
[Learn more about policies here](/docs/policies).
---
### Image attachments
OpenCode normalizes image attachments before sending them to the model. By default, images are resized when they exceed `2000x2000` pixels or `5242880` base64 bytes.

View file

@ -0,0 +1,127 @@
---
title: Policies
description: Control which configured resources OpenCode may use.
---
Policies control whether OpenCode may perform an action on a named resource. They are configured with the `policies` array in `opencode.json`.
Policies are separate from [permissions](/docs/permissions). Permissions control what tools can do during a session, while policies control whether OpenCode may use a resource such as an LLM provider.
---
## Configuration
Each policy statement has three fields:
- `effect` - Either `"allow"` or `"deny"`.
- `action` - The operation being controlled.
- `resource` - The resource ID or wildcard pattern the statement applies to.
For example, deny use of the `openai` provider:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "openai"
}
]
}
```
A provider denied by policy is not available for model selection or model use, even if it has credentials or is otherwise configured correctly.
---
## Available Policies
OpenCode currently supports one policy action:
| Action | Resource | Description |
| -------------- | ------------------------------ | ------------------------------------------ |
| `provider.use` | Provider ID, such as `openai` | Allow or deny use of an LLM provider. |
More policy actions may be added in the future.
---
## Matching
The `resource` field supports wildcard matching. Use `*` to match zero or more characters and `?` to match one character.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "company-*"
}
]
}
```
This denies providers such as `company-us` and `company-eu`.
---
## Rule Order
When multiple statements match, the last matching statement wins. Put broad rules first, then more specific exceptions after them.
For example, allow only Anthropic:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "*"
},
{
"effect": "allow",
"action": "provider.use",
"resource": "anthropic"
}
]
}
```
If no policy matches a provider, provider use is allowed by default.
Policies may be set in both your global config and project config. If policies from both locations match the same provider, your global policy takes priority over the project policy. This prevents a repository from re-enabling a provider that you deny globally.
---
## Provider Lists
Use policies instead of the older `disabled_providers` and `enabled_providers` settings when controlling provider access.
To replace `disabled_providers`:
```json title="opencode.json"
{
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "openai" },
{ "effect": "deny", "action": "provider.use", "resource": "google" }
]
}
```
To replace `enabled_providers`, deny all providers first and allow the selected providers after it:
```json title="opencode.json"
{
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "anthropic" },
{ "effect": "allow", "action": "provider.use", "resource": "openai" }
]
}
```