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