feat(core): add provider policy enforcement

This commit is contained in:
Dax Raad
2026-05-27 22:07:26 -04:00
parent 9e556b0f6c
commit e24b589da1
22 changed files with 946 additions and 22 deletions
+20 -5
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),
)
+14 -2
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),
)
+15
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),
}) {}
+5 -2
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: [],
}) {}
+44
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
+21
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())
}),
)
})
+96
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()),
@@ -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") }))),
),
@@ -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") }))),
),
@@ -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("/") })),
),
)
@@ -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),
),
@@ -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))),
),
)
})
+83
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")
}),
)
})
+4
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",
}),
+12 -3
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),
),
)
@@ -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* () {
@@ -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(
+1
View File
@@ -259,6 +259,7 @@ export default defineConfig({
"commands",
"formatters",
"permissions",
"policies",
"lsp",
"mcp-servers",
"acp",
+21
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.
+127
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" }
]
}
```
+169
View File
@@ -0,0 +1,169 @@
# V2 Config Review
This document breaks the legacy configuration schema into small review groups. Work through one group at a time and decide whether each field should be ported as-is, removed, or redesigned for v2.
## Status Labels
- `pending`: not discussed yet
- `keep`: port with substantially the existing meaning
- `remove`: do not carry forward
- `redesign`: keep the capability with a different shape, scope, or owning module
## Schema Scope
Use one v2 config schema for now. Some fields, such as `autoupdate`, are intended for global/user configuration, but there is not yet enough benefit to enforce that with separate global and location schemas. Revisit this if more scope-sensitive fields survive the review.
## Group 1: File Metadata
Small fields describing the config file itself rather than application behavior.
| Field | Current Purpose | Status | Notes |
| --------- | ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------- |
| `$schema` | JSON schema reference for editor validation and completion | keep | Keep as read-only metadata; loading config must not insert it or create files for it. |
## Group 2: Process And Server Settings
Settings that affect process startup, shell execution, or network serving. Review global-only versus location-specific scope carefully.
| Field | Current Purpose | Status | Notes |
| ------------ | --------------------------------------------------- | ------ | ------------------------------------------------------------------------------ |
| `shell` | Default shell for terminal and shell tool execution | keep | Port as effective config; shared shell choice is used throughout opencode. |
| `logLevel` | Intended logging level configuration | remove | Do not port: no config consumer exists and logging initializes from CLI input. |
| `server` | Hostname, port, mDNS, and CORS settings | remove | Do not port: location config is loaded after the server is already running. |
| `autoupdate` | Automatic update or notification behavior | keep | Global-only user preference; keep `true`, `false`, and `"notify"`. |
## Group 3: Commands And Project Resources
Configuration that introduces location-scoped project resources or discoverable content.
| Field | Current Purpose | Status | Notes |
| -------------- | --------------------------------------- | ------- | ----- |
| `command` | User-defined commands | pending | |
| `skills` | Additional skill locations | pending | |
| `reference` | Named git or local directory references | pending | |
| `instructions` | Additional instruction file patterns | pending | |
## Group 4: Plugins
Plugin loading has source-path and scope-sensitive behavior, so it should be reviewed separately from other project resources.
| Field | Current Purpose | Status | Notes |
| -------- | ----------------------------- | ------- | ------------------------------------------------------ |
| `plugin` | User-specified plugin modules | pending | Existing loader records origin and global/local scope. |
## Group 5: Filesystem And Tool Runtime
Settings controlling local file observation, snapshots, language tooling, and tool output behavior.
| Field | Current Purpose | Status | Notes |
| ------------- | --------------------------------------- | ------- | ----- |
| `watcher` | Ignore patterns for filesystem watching | pending | |
| `snapshot` | Enable filesystem snapshot tracking | pending | |
| `formatter` | Configure formatters | pending | |
| `lsp` | Configure language servers | pending | |
| `attachment` | Configure attachment/image processing | pending | |
| `tool_output` | Configure tool output truncation limits | pending | |
## Group 6: Sharing And Identity
Settings affecting sharing behavior or user/account identity rather than model execution.
| Field | Current Purpose | Status | Notes |
| ------------ | ----------------------------------------------- | ------- | ------------------------------- |
| `share` | Session sharing behavior | pending | |
| `autoshare` | Legacy automatic sharing flag | pending | Deprecated in favor of `share`. |
| `enterprise` | Enterprise URL configuration | pending | |
| `username` | Display username in conversations and telemetry | pending | |
## Group 7: Providers And Model Selection
Provider catalog customization and model-choice configuration. The new core work has started here.
| Field | Current Purpose | Status | Notes |
| -------------------- | ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- |
| `provider` | Custom provider configuration and model overrides | pending | New core schema currently uses `providers`; decide public key compatibility. |
| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. |
| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. |
| `model` | Default model selection | pending | |
| `small_model` | Small/utility model selection | pending | |
Provider selection rules belong in a plural `policies` array rather than provider entries or repeated top-level provider fields. Initial proposed shape:
```jsonc
{
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "*",
},
{
"effect": "allow",
"action": "provider.use",
"resource": "anthropic",
},
],
}
```
See [provider-policy.md](./provider-policy.md) for the provider policy semantics and precedence rules.
Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.opencode` policy sources remains open until `.opencode` configuration is reviewed.
## Group 8: Agents And Permissions
Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices.
| Field | Current Purpose | Status | Notes |
| --------------- | --------------------------------------------------- | ------- | ------------------------------------------- |
| `default_agent` | Choose default primary agent | pending | |
| `mode` | Legacy agent configuration alias | pending | Deprecated in favor of `agent`. |
| `agent` | Configure primary, subagent, and specialized agents | pending | |
| `permission` | Tool permission rules | pending | |
| `tools` | Legacy tool enable/disable map | pending | Converted to permissions by current loader. |
## Group 9: Integrations
External protocol and server integration configuration.
| Field | Current Purpose | Status | Notes |
| ----- | ------------------------------------- | ------- | ----- |
| `mcp` | MCP server definitions and enablement | pending | |
## Group 10: Conversation Lifecycle
Behavior affecting long-running conversations and context management.
| Field | Current Purpose | Status | Notes |
| ------------ | ----------------------------------------------------------- | ------- | ----- |
| `compaction` | Automatic compaction, pruning, and context reserve settings | pending | |
## Group 11: Deprecated And Experimental Settings
Fields that should not be ported by inertia; each needs an explicit justification.
| Field | Current Purpose | Status | Notes |
| ------------------------------------ | --------------------------------------- | ------- | ------------------------------------------------------------------- |
| `layout` | Legacy layout selection | pending | Deprecated; current description says stretch layout is always used. |
| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | pending | |
| `experimental.batch_tool` | Enable batch tool | pending | |
| `experimental.openTelemetry` | Enable AI SDK telemetry spans | pending | |
| `experimental.primary_tools` | Restrict tools to primary agents | pending | |
| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | pending | |
| `experimental.mcp_timeout` | MCP request timeout | pending | May belong with MCP rather than experiments. |
## Review Order
Work through the groups in this order unless a dependency between decisions becomes clear:
1. File Metadata
2. Process And Server Settings
3. Providers And Model Selection
4. Commands And Project Resources
5. Plugins
6. Filesystem And Tool Runtime
7. Sharing And Identity
8. Agents And Permissions
9. Integrations
10. Conversation Lifecycle
11. Deprecated And Experimental Settings
+275
View File
@@ -0,0 +1,275 @@
# Policy
## Purpose
Policies control whether an operation on a named resource is allowed. They may be authored in configuration files, but policy evaluation is its own runtime concern.
The first policy consumer is provider availability:
```text
action: provider.use
resource: provider ID, such as openai or company-ai
```
Provider configuration and provider policy remain separate:
- `providers` describes endpoints, options, and model overrides.
- `policies` determines whether an operation using a provider is allowed.
A provider can be correctly configured and have valid credentials while policy still denies its use.
## Goals
- Replace legacy `enabled_providers` and `disabled_providers`.
- Keep the default experience unchanged when users specify no policy.
- Support wildcard matching for actions and resources.
- Provide one small policy vocabulary that can later cover operations such as `plugin.load` or `mcp.connect`.
- Let user policy override repository policy, and later allow organization-managed policy to override both.
- Keep evaluation simple: matching statements are applied in order and the last match wins.
## Non-Goals
- Policies do not configure endpoints, credentials, models, or provider options.
- Policies do not make unusable resources usable.
- Policies do not currently provide conditions, principals, approval prompts, or enforced configuration values.
- This spec does not define how organization-managed policies are delivered.
## Statement Shape
```jsonc
{
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "openai",
},
],
}
```
```ts
interface PolicyInfo {
effect: "allow" | "deny"
action: string
resource: string
}
```
The `Policy` module owns the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` fixes `action` to `"provider.use"`. The config schema gathers those domain-defined statement schemas into the accepted `policies` union because config files are one place statements can be authored.
## Matching
Both `action` and `resource` use opencode's existing wildcard matching behavior.
Examples:
| Action | Resource | Matches |
| -------------- | ----------- | ---------------------------------------------------------------------------- |
| `provider.use` | `openai` | Only use of provider ID `openai` |
| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` |
| `provider.*` | `*` | Any provider operation on any provider, if more actions are introduced later |
No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result.
## Evaluation
To evaluate an operation and resource:
1. Start with `allow`.
2. Consider every statement whose `action` and `resource` match the requested action and resource.
3. Each matching statement replaces the current decision with its `effect`.
4. The last matching statement determines the result.
Conceptually:
```ts
function evaluate(action: string, resource: string, fallback: Policy.Effect, statements: Policy.Info[]) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}
```
Each caller supplies the default effect appropriate for its operation. Catalog provider use supplies `"allow"`, so no provider policy statements means normal behavior continues: otherwise usable providers are allowed.
## Ordering Within One Config Document
Statements remain in the order written by the user.
To deny all providers except Anthropic:
```jsonc
{
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "*",
},
{
"effect": "allow",
"action": "provider.use",
"resource": "anthropic",
},
],
}
```
Result:
```text
provider.use / anthropic -> allow
provider.use / openai -> deny
```
To allow internal providers except experimental ones:
```jsonc
{
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "company-*" },
{ "effect": "deny", "action": "provider.use", "resource": "company-experimental-*" },
],
}
```
Result:
```text
company-stable: allowed
company-experimental-fast: denied
openai: denied
```
## Ordering Across Authored Config Documents
Ordinary settings and policies have different precedence needs:
- Ordinary settings are read forward, so location-specific settings override user-global settings.
- Policies are read by reversing authored config documents, so user-global policy can override repository policy.
- Statements inside each document keep their written order.
At minimum, this means a repository cannot silently re-enable something the user denied globally.
Project config:
```jsonc
{
"policies": [{ "effect": "allow", "action": "provider.use", "resource": "openai" }],
}
```
User-global config:
```jsonc
{
"policies": [{ "effect": "deny", "action": "provider.use", "resource": "openai" }],
}
```
Result:
```text
provider.use / openai -> deny
```
The relative policy precedence of direct project files and `.opencode` files is intentionally deferred until `.opencode` configuration is reviewed.
## Organization-Managed Policy
Organization-managed policy is not ordinary authored config. When implemented, managed statements must be appended after the reversed authored statements so they have final authority.
```text
repository policy -> user-global policy -> organization-managed policy
```
Plugins must not be allowed to add, remove, or override policy statements. Plugins can contribute functionality or configured providers; policy determines whether opencode permits an operation through its managed execution paths.
Provider policy is not a full sandbox for executable plugins. A denied provider must not be usable through the normal provider/model path, but arbitrary plugin code requires separate governance if that becomes a compliance requirement.
## Interaction With Provider Configuration
```jsonc
{
"providers": {
"company-ai": {
"endpoint": {
"type": "openai/responses",
"url": "https://ai.company.example/v1/responses",
},
},
},
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "company-ai" },
],
}
```
The provider entry configures `company-ai`; the policy statements make it the only provider permitted for use.
Provider policy applies regardless of how a provider becomes known or usable, including:
- models.dev catalog data
- environment credentials
- saved accounts
- built-in provider plugins
- explicit provider configuration
## 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.
Intended 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.
Whether denied providers are removed entirely or retained as disabled records for diagnostics remains an implementation decision.
## Legacy Migration
Legacy deny list:
```jsonc
{
"disabled_providers": ["openai", "google"],
}
```
Equivalent v2 policy:
```jsonc
{
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "openai" },
{ "effect": "deny", "action": "provider.use", "resource": "google" },
],
}
```
Legacy allowlist:
```jsonc
{
"enabled_providers": ["anthropic", "openai"],
}
```
Equivalent v2 policy:
```jsonc
{
"policies": [
{ "effect": "deny", "action": "provider.use", "resource": "*" },
{ "effect": "allow", "action": "provider.use", "resource": "anthropic" },
{ "effect": "allow", "action": "provider.use", "resource": "openai" },
],
}
```