Compare commits

...
Author SHA1 Message Date
Adam c7106c1628 feat(core): enforce managed provider policies 2026-09-10 18:41:04 -05:00
22 changed files with 850 additions and 99 deletions
+16 -2
View File
@@ -8,6 +8,7 @@ import { Provider } from "./provider.js"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { Integration } from "./integration.js"
import { ProviderPolicy } from "./provider-policy.js"
export type ProviderRecord = {
provider: Provider.MutableInfo
@@ -63,6 +64,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const integrations = yield* Integration.Service
const policies = yield* ProviderPolicy.Service
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
if (provider.activation === "disabled") return false
@@ -146,11 +148,15 @@ const layer = Layer.effect(
provider: {
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
return state.get().providers.get(providerID)?.provider
}),
all: Effect.fn("Catalog.provider.all")(function* () {
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
const policy = yield* policies.read()
return Array.fromIterable(state.get().providers.values())
.map((record) => record.provider)
.filter((provider) => ProviderPolicy.allows(policy, provider.id))
}),
available: Effect.fn("Catalog.provider.available")(function* () {
@@ -163,6 +169,7 @@ const layer = Layer.effect(
model: {
get: Effect.fn("Catalog.model.get")(function* (providerID, modelID) {
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID)
@@ -170,8 +177,10 @@ const layer = Layer.effect(
}),
all: Effect.fn("Catalog.model.all")(function* () {
const policy = yield* policies.read()
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.filter((record) => ProviderPolicy.allows(policy, record.provider.id)),
Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
@@ -209,6 +218,7 @@ const layer = Layer.effect(
}),
small: Effect.fn("Catalog.model.small")(function* (providerID) {
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
const record = state.get().providers.get(providerID)
if (!record) return
const models = pipe(
@@ -237,4 +247,8 @@ const layer = Layer.effect(
const SMALL_MODEL_FAMILY_PRIORITY = ["gpt-luna", "gemini-flash-lite", "gemini-flash", "claude-haiku"]
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Integration.node, ProviderPolicy.node],
})
-27
View File
@@ -1,27 +0,0 @@
export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Document } from "@opencode/schema/config"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Wildcard } from "../../util/wildcard.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.catalog.reload())
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
for (const record of catalog.provider.list()) {
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
}
})
}),
})
+4
View File
@@ -72,6 +72,10 @@ export const layer = Layer.effect(
const text: Interface["text"] = (input) =>
runText(input).pipe(
Effect.catchTag(
["ProviderPolicy.Unavailable", "ProviderPolicy.Denied"],
(error) => new UnavailableError({ message: error.message }),
),
Effect.catchTag(
"Integration.Authorization",
() =>
+7 -1
View File
@@ -12,6 +12,7 @@ import { Integration } from "./integration.js"
import { Capabilities, ID, Info, Ref, VariantID } from "./model.js"
import { Npm } from "@opencode/util/npm"
import { Provider } from "./provider.js"
import { ProviderPolicy } from "./provider-policy.js"
export class VariantUnavailableError extends Schema.TaggedError<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
@@ -66,6 +67,8 @@ export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCo
}
export type Error =
| ProviderPolicy.Unavailable
| ProviderPolicy.Denied
| VariantUnavailableError
| UnsupportedPackageError
| UnresolvedProviderVariablesError
@@ -291,10 +294,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
yield* policies.assert(selected.providerID)
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
@@ -328,6 +333,7 @@ export const layer = Layer.effect(
})
return Service.of({
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
yield* policies.assert(requested?.providerID)
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model
@@ -394,5 +400,5 @@ function usesAPIKeyAuth(packageName: string | undefined) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node, ProviderPolicy.node],
})
+3 -2
View File
@@ -20,7 +20,7 @@ import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ProviderPolicy } from "../provider-policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
@@ -98,6 +98,7 @@ const services = [
Agent.Service,
AppProcess.Service,
Catalog.Service,
ProviderPolicy.Service,
Command.Service,
Config.Service,
Credential.Service,
@@ -147,6 +148,7 @@ export const requirements = LayerNode.group([
Agent.node,
AppProcess.node,
Catalog.node,
ProviderPolicy.node,
Command.node,
Config.node,
Credential.node,
@@ -241,7 +243,6 @@ const post = [
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
+85 -11
View File
@@ -10,6 +10,8 @@ import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { ProviderPolicy } from "../../provider-policy.js"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
@@ -19,7 +21,27 @@ const RemoteResponse = Schema.Struct({
websearch: Schema.Struct({
providerID: WebSearch.ID,
}).pipe(Schema.optional),
experimental: Schema.Unknown,
managedPolicy: Schema.Unknown,
})
const RemotePolicy = Schema.Struct({
experimental: Schema.Struct({
policies: Schema.Array(ConfigPolicy.Info).check(
Schema.makeFilter((statements) =>
statements.every(
(statement) =>
statement.resource.length > 0 &&
statement.resource.length <= 256 &&
statement.resource.trim() === statement.resource,
),
),
),
}),
managedPolicy: ProviderPolicy.Descriptor,
})
class RemoteFailure extends Schema.TaggedError<RemoteFailure>()("OpenCode.RemoteConfigFailure", {
transient: Schema.Boolean,
}) {}
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -102,31 +124,41 @@ function oauth(http: HttpClient.HttpClient) {
} satisfies IntegrationOAuthMethodRegistration
}
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ProviderPolicy.Service | Scope.Scope>({
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const policies = yield* ProviderPolicy.Service
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
config: typeof RemoteResponse.Type | undefined
config: Effect.Success<ReturnType<typeof fetchConfig>> | undefined
connection: ActiveConnection
} = { config: undefined, connection: undefined }
identity?: string
stale: boolean
} = { config: undefined, connection: undefined, stale: false }
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const identity = connection && credential ? ProviderPolicy.identity(connection, credential) : undefined
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
Effect.logWarning("Failed to load workspace provider policy", { transient: cause.transient }).pipe(
Effect.as(
cause.transient && identity !== undefined && snapshot.identity === identity && snapshot.config
? { ...snapshot.config, stale: true }
: undefined,
),
),
),
)
: undefined
return { config, connection }
return { config, connection, identity, stale: config?.stale ?? false }
})
yield* ctx.integration.transform((editor) => {
@@ -138,6 +170,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
})
snapshot = yield* load()
yield* policies.set(
snapshot.config && snapshot.identity
? {
identity: snapshot.identity,
descriptor: snapshot.config.managedPolicy,
statements: snapshot.config.experimental.policies,
stale: snapshot.stale,
}
: undefined,
)
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
@@ -284,6 +326,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* policies.set(
next.config && next.identity
? {
identity: next.identity,
descriptor: next.config.managedPolicy,
statements: next.config.experimental.policies,
stale: next.stale,
}
: undefined,
)
yield* Effect.all([ctx.catalog.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
@@ -307,27 +359,49 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}),
})
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
const fetchConfig = Effect.fn("OpenCode.fetchConfig")(function* (http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = value.type === "oauth" ? value.access : value.key
return http
const server = yield* normalizeServer(serverUrl(value)).pipe(
Effect.mapError(() => new RemoteFailure({ transient: false })),
)
return yield* http
.execute(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
),
)
.pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.mapError(() => new RemoteFailure({ transient: true })),
Effect.flatMap((response) => {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
if (response.status < 200 || response.status >= 300)
return Effect.fail(new RemoteFailure({ transient: response.status >= 500 || response.status === 429 }))
return Effect.succeed(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.flatMap((config) =>
Schema.decodeUnknownEffect(RemotePolicy, { onExcessProperty: "error" })({
experimental: config.experimental,
managedPolicy: config.managedPolicy,
}).pipe(Effect.map((policy) => ({ ...config, ...policy, stale: false }))),
),
Effect.flatMap((config) =>
orgID !== undefined && config.managedPolicy.workspaceID !== orgID
? Effect.fail(new RemoteFailure({ transient: false }))
: Effect.succeed(config),
),
Effect.mapError(() => new RemoteFailure({ transient: false })),
)
}),
Effect.timeoutOrElse({
duration: Duration.seconds(20),
orElse: () => Effect.fail(new RemoteFailure({ transient: true })),
}),
)
}
})
function serverUrl(value: Credential.Value) {
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
+3
View File
@@ -88,6 +88,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
enabled.add(plugin.id)
}
// Console's connected-workspace policy source cannot be removed by authored
// plugin directives. Its final enforcement lives outside the plugin registry.
if (pre.some((plugin) => plugin.id === "opencode.provider.opencode")) enabled.add("opencode.provider.opencode")
const ordered = [
...pre.filter((plugin) => enabled.has(plugin.id)),
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
+125
View File
@@ -0,0 +1,125 @@
export * as ProviderPolicy from "./provider-policy.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { Event } from "@opencode/schema/config"
import { Catalog } from "@opencode/schema/catalog"
import { ProviderPolicyMatcher } from "@opencode/util/opencode-policy-matcher"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { Bus } from "./bus.js"
import { Config } from "./config.js"
import { Credential } from "./credential.js"
import { Integration } from "./integration.js"
import { IntegrationConnection } from "./integration/connection.js"
export class Unavailable extends Schema.TaggedError<Unavailable>()("ProviderPolicy.Unavailable", {}) {
override get message() {
return "Workspace policy unavailable. Reconnect to OpenCode Console and try again."
}
}
export class Denied extends Schema.TaggedError<Denied>()("ProviderPolicy.Denied", { providerID: Schema.String }) {
override get message() {
return `Provider ${this.providerID} is denied by your OpenCode provider policy.`
}
}
export const Descriptor = Schema.Struct({
schemaVersion: Schema.Literal(1),
workspaceID: Schema.String.check(Schema.isNonEmpty()),
revision: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
})
export type Managed = {
readonly identity: string
readonly descriptor: typeof Descriptor.Type
readonly statements: readonly ConfigPolicy.Info[]
readonly stale: boolean
}
export type Snapshot = {
readonly status: "disconnected" | "ready" | "stale" | "unavailable"
readonly statements: readonly ConfigPolicy.Info[]
readonly workspaceID?: string
readonly revision?: number
}
export interface Interface {
readonly read: () => Effect.Effect<Snapshot>
readonly set: (value: Managed | undefined) => Effect.Effect<void>
readonly assert: (providerID?: string) => Effect.Effect<void, Unavailable | Denied>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderPolicy") {}
// Internal connection binding; this value can contain a service key and must never be logged or exposed.
export function identity(
connection: Pick<IntegrationConnection.Info, "type"> & { readonly id?: string; readonly name?: string },
credential: Credential.Value,
) {
return JSON.stringify([
connection.type,
connection.id ?? connection.name,
credential.metadata?.server ?? "https://opencode.ai/console",
credential.metadata?.orgID,
credential.type === "key" ? credential.key : undefined,
])
}
export function allows(snapshot: Snapshot, providerID: string) {
return (
snapshot.status !== "unavailable" &&
ProviderPolicyMatcher.evaluate(snapshot.statements, providerID, process.platform === "win32").effect !== "deny"
)
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
let managed: Managed | undefined
const read = Effect.fn("ProviderPolicy.read")(function* () {
const statements = (yield* config.entries())
.filter((entry) => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
const connection = yield* integrations.connection.active(Integration.ID.make("opencode"))
if (!connection) return { status: "disconnected", statements } as const
const credential =
connection.type === "credential"
? (yield* credentials.get(connection.id))?.value
: process.env[connection.name]
? Credential.Key.make({ type: "key", key: process.env[connection.name]! })
: undefined
if (!credential || !managed || managed.identity !== identity(connection, credential))
return { status: "unavailable", statements } as const
return {
status: managed.stale ? "stale" : "ready",
statements: [...statements, ...managed.statements],
workspaceID: managed.descriptor.workspaceID,
revision: managed.descriptor.revision,
} as const
})
yield* bus.subscribe(Event.Updated).pipe(
Stream.runForEach(() => bus.publish(Catalog.Event.Updated, {})),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({
read,
set: (value) =>
Effect.sync(() => {
managed = value
}),
assert: Effect.fn("ProviderPolicy.assert")(function* (providerID) {
const snapshot = yield* read()
if (snapshot.status === "unavailable") return yield* new Unavailable()
if (providerID !== undefined && !allows(snapshot, providerID)) return yield* new Denied({ providerID })
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, Integration.node, Credential.node, Bus.node],
})
+4 -1
View File
@@ -6,6 +6,7 @@ import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Context, Effect, Layer, Schema } from "effect"
import { ModelResolver } from "../../model-resolver.js"
import { ProviderPolicy } from "../../provider-policy.js"
import { SessionSchema } from "../schema.js"
export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedError>()(
@@ -80,8 +81,10 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const policies = yield* ProviderPolicy.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
yield* policies.assert(session.model?.providerID)
// Location plugins populate and filter the catalog asynchronously during layer startup.
if (!session.model) {
const resolved = yield* resolver.resolve()
@@ -102,4 +105,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node, ProviderPolicy.node] })
@@ -3,6 +3,7 @@ import { Tool } from "@opencode/schema/tool"
import { SessionError } from "@opencode/schema/session-error"
import { Permission } from "../permission.js"
import { Integration } from "../integration.js"
import { ProviderPolicy } from "../provider-policy.js"
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -51,6 +52,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
cause instanceof ProviderPolicy.Unavailable ||
cause instanceof ProviderPolicy.Denied ||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
+10 -16
View File
@@ -2,10 +2,7 @@ import { describe, expect } from "bun:test"
import { Document, Event, Info, type Entry } from "@opencode/schema/config"
import { Catalog } from "@opencode/core/catalog"
import { Config } from "@opencode/core/config"
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
import { Bus } from "@opencode/core/bus"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { Provider } from "@opencode/core/provider"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
@@ -24,13 +21,13 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
}),
})
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
const setEntries = Effect.fn(function* (entries: Entry[]) {
const config = yield* Config.Service
const current = yield* config.entries()
current.splice(0, current.length, ...entries)
})
describe("ConfigPolicyPlugin.Plugin", () => {
describe("Provider policy catalog boundary", () => {
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -39,7 +36,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
catalog.provider.update(Provider.ID.anthropic, () => {})
catalog.provider.update(Provider.ID.make("company-internal"), () => {})
})
yield* addPlugin([
yield* setEntries([
policies(
{ effect: "deny", resource: "*" },
{ effect: "allow", resource: "anthropic" },
@@ -57,7 +54,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* addPlugin([
yield* setEntries([
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
@@ -70,17 +67,14 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const test = yield* Config.Test
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* ConfigPolicyPlugin.Plugin.effect(host)
yield* setEntries([policies({ effect: "deny", resource: "openai" })])
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
yield* setEntries([policies({ effect: "allow", resource: "openai" })])
yield* bus.publish(Event.Updated, {})
yield* waitUntil(catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
}),
)
})
+5 -1
View File
@@ -1,3 +1,4 @@
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { expect } from "bun:test"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
@@ -67,7 +68,10 @@ const aisdk = Layer.mock(AISDK.Service, {
})
const client = TestLLM.testLayer({ fallback: TestLLM.text("OK", "generate") })
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const resolver = ModelResolver.layer.pipe(
Layer.provide(Layer.mock(ProviderPolicy.Service, { assert: () => Effect.void })),
Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)),
)
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
const resolverIt = testEffect(resolver)
+5 -1
View File
@@ -1,3 +1,4 @@
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { describe, expect } from "bun:test"
import { LLM, LanguageModel, Message } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
@@ -384,7 +385,10 @@ describe("ModelResolver", () => {
},
model: () => Effect.die("unused"),
})
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const layer = ModelResolver.layer.pipe(
Layer.provide(Layer.mock(ProviderPolicy.Service, { assert: () => Effect.void })),
Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)),
)
return withConfigEnv({}, () =>
Effect.gen(function* () {
+3
View File
@@ -1,6 +1,7 @@
import { Agent } from "@opencode/core/agent"
import { AISDK } from "@opencode/core/aisdk"
import { Catalog } from "@opencode/core/catalog"
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { Command } from "@opencode/core/command"
import { Config } from "@opencode/core/config"
import { Credential } from "@opencode/core/credential"
@@ -78,6 +79,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
Agent.node,
AISDK.node,
Catalog.node,
ProviderPolicy.node,
Config.node,
Command.node,
Integration.node,
KV.node,
@@ -89,6 +89,10 @@ function eventually<A>(
})
}
function managedPolicy(workspaceID = "org_test") {
return { experimental: { policies: [] }, managedPolicy: { schemaVersion: 1, workspaceID, revision: 0 } }
}
const cost = (input: number, output = 0) => [
{
input: Money.USDPerMillionTokens.make(input),
@@ -366,6 +370,7 @@ describe("OpencodePlugin", () => {
requests.push(`${request.method} ${new URL(request.url).pathname}`)
const origin = new URL(request.url).origin
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {
remote: {
canonical: "openai",
@@ -544,9 +549,10 @@ describe("OpencodePlugin", () => {
const state = { advertised: false, requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
fetch: (request) => {
state.requests++
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
})
@@ -634,6 +640,7 @@ describe("OpencodePlugin", () => {
if (path === "/api/v2/config") {
if (state.waitForConfig) await gate.promise
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
...(state.advertised
? {
@@ -781,6 +788,7 @@ describe("OpencodePlugin", () => {
fetch: (request) => {
if (new URL(request.url).pathname === "/console/api/v2/config") {
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
websearch: {
providerID: "managed-search",
@@ -843,6 +851,7 @@ describe("OpencodePlugin", () => {
requests.push(url.pathname)
if (url.pathname === "/console/api/v2/config") {
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
websearch: {
providerID: "opencode",
@@ -893,6 +902,7 @@ describe("OpencodePlugin", () => {
const url = new URL(request.url)
if (url.pathname === "/api/v2/config") {
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
websearch: {
providerID: "opencode",
@@ -987,6 +997,7 @@ describe("OpencodePlugin", () => {
})
}
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {
remote: {
canonical: "openai",
@@ -1152,13 +1163,12 @@ describe("OpencodePlugin", () => {
draft.cost = cost(1)
})
})
// An env credential has no server metadata, so the plugin would ask the
// default Console for remote config; answer 404 (no remote config) locally.
// Environment credentials also require a valid managed-policy response.
yield* addPlugin().pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))),
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ providers: {}, ...managedPolicy() }))),
),
),
)
@@ -0,0 +1,51 @@
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
// Run from Console's policy E2E with a freshly issued local service-account key.
describe.skipIf(!process.env.OPENCODE_CONSOLE_POLICY_URL)("Published Console policy", () => {
it.live("loads the real managed config and enforces the policy published in the browser", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
yield* catalog.transform((editor) => {
editor.provider.update(Provider.ID.openai, () => {})
editor.provider.update(Provider.ID.make("company-prod"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: process.env.OPENCODE_CONSOLE_POLICY_KEY!,
metadata: {
server: process.env.OPENCODE_CONSOLE_POLICY_URL!,
orgID: process.env.OPENCODE_CONSOLE_POLICY_ORG!,
},
}),
})
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* OpencodePlugin.effect(host)
expect(yield* policies.read()).toMatchObject({
status: "ready",
workspaceID: process.env.OPENCODE_CONSOLE_POLICY_ORG,
revision: 1,
})
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(Provider.ID.make("company-prod"))).toBeDefined()
expect(yield* policies.assert("openai").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Denied)
}),
)
})
@@ -0,0 +1,207 @@
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode/core/catalog"
import { Config } from "@opencode/core/config"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { Document, Info } from "@opencode/schema/config"
import { Effect, Schema } from "effect"
import { TestClock } from "effect/testing"
import { drain } from "../lib/clock"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const rule = (effect: "allow" | "deny", resource: string) => ({ action: "provider.use" as const, effect, resource })
const document = (policies: ReturnType<typeof rule>[], workspaceID = "org_policy", revision = 1) => ({
providers: {},
experimental: { policies },
managedPolicy: { schemaVersion: 1, workspaceID, revision },
})
const activate = Effect.gen(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* OpencodePlugin.effect(host)
})
const refresh = TestClock.adjust("10 minutes").pipe(Effect.andThen(drain))
describe("Managed provider policy", () => {
it.effect("accepts revision-zero defaults without requiring policy publication", () =>
Effect.acquireUseRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch: () => Response.json(document([], "org_policy", 0)) })),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.openai, () => {}))
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "test-key",
metadata: { server: server.url.origin, orgID: "org_policy" },
}),
})
yield* activate
expect(yield* policies.read()).toMatchObject({ status: "ready", revision: 0, workspaceID: "org_policy" })
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
yield* policies.assert("openai")
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect(
"composes organization rules last, filters every catalog view, retains only valid same-identity snapshots, and clears explicitly",
() =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { status: 200, body: document([rule("deny", "*"), rule("allow", "remote")]) as unknown }
const server = Bun.serve({ port: 0, fetch: () => Response.json(state.body, { status: state.status }) })
return { state, server }
}),
({ state, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const policies = yield* ProviderPolicy.Service
const config = yield* Config.Service
const entries = yield* config.entries()
entries.push(
new Document({
type: "document",
info: Schema.decodeUnknownSync(Info)({
experimental: { policies: [rule("deny", "remote"), rule("allow", "openai")] },
}),
}),
)
yield* catalog.transform((editor) => {
for (const id of ["openai", "remote"]) {
editor.provider.update(Provider.ID.make(id), (provider) => {
provider.activation = "enabled"
})
editor.model.update(Provider.ID.make(id), Model.ID.make("model"), () => {})
}
})
const previouslySelected = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("model"))
if (!previouslySelected) return yield* Effect.die("Expected local model")
const credential = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "test-key",
metadata: { server: server.url.origin, orgID: "org_policy" },
}),
})
expect((yield* policies.read()).status).toBe("unavailable")
expect(yield* catalog.provider.all()).toEqual([])
yield* activate
expect((yield* policies.read()).status).toBe("ready")
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
expect((yield* catalog.model.all()).map((model) => model.providerID)).toEqual([Provider.ID.make("remote")])
expect(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("model"))).toBeUndefined()
expect(yield* catalog.model.small(Provider.ID.openai)).toBeUndefined()
yield* catalog.transform((editor) =>
editor.provider.update(Provider.ID.openai, (provider) => {
provider.activation = "enabled"
}),
)
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
expect(yield* policies.assert("openai").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Denied)
const loadError = yield* Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
return yield* resolver.resolveModel(previouslySelected).pipe(Effect.flip)
}).pipe(Effect.provide(ModelResolver.layer))
expect(loadError).toBeInstanceOf(ProviderPolicy.Denied)
state.status = 503
yield* refresh
expect((yield* policies.read()).status).toBe("stale")
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
state.status = 200
state.body = { providers: {} }
yield* refresh
expect((yield* policies.read()).status).toBe("unavailable")
expect(yield* catalog.model.available()).toEqual([])
expect(yield* policies.assert("remote").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Unavailable)
state.body = document([], "org_policy", 2)
yield* refresh
expect((yield* policies.read()).revision).toBe(2)
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeUndefined()
state.status = 503
yield* credentials.update(credential.id, {
value: Credential.Key.make({
type: "key",
key: "other-key",
metadata: { server: server.url.origin, orgID: "org_other" },
}),
})
yield* drain
expect((yield* policies.read()).status).toBe("unavailable")
state.status = 200
yield* refresh
expect((yield* policies.read()).status).toBe("unavailable")
state.body = document([rule("deny", "*")], "org_other", 0)
yield* refresh
expect((yield* policies.read()).workspaceID).toBe("org_other")
expect(yield* catalog.provider.all()).toEqual([])
yield* credentials.remove(credential.id)
yield* drain
expect((yield* policies.read()).status).toBe("disconnected")
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
for (const scenario of [
{ name: "missing descriptor", body: { providers: {}, experimental: { policies: [] } }, status: 200 },
{
name: "unsupported version",
body: { ...document([]), managedPolicy: { schemaVersion: 2, workspaceID: "org_policy", revision: 1 } },
status: 200,
},
{ name: "unknown conditions", body: document([{ ...rule("allow", "*"), ...{ condition: {} } }]), status: 200 },
{ name: "empty resource", body: document([rule("allow", "")]), status: 200 },
{ name: "absent policies", body: { ...document([]), experimental: {} }, status: 200 },
{ name: "revoked authorization", body: document([]), status: 403 },
{ name: "old Console", body: {}, status: 404 },
{ name: "cold outage", body: {}, status: 503 },
]) {
it.effect(`fails closed on ${scenario.name}`, () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({ port: 0, fetch: () => Response.json(scenario.body, { status: scenario.status }) }),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.openai, () => {}))
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "test-key",
metadata: { server: server.url.origin, orgID: "org_policy" },
}),
})
yield* activate
expect((yield* policies.read()).status).toBe("unavailable")
expect(yield* catalog.provider.all()).toEqual([])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
}
})
@@ -10,6 +10,7 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { Command } from "@opencode/core/command"
import { Database } from "@opencode/core/database/database"
import { Watcher } from "@opencode/core/filesystem/watcher"
@@ -111,6 +112,36 @@ const failed = (plugins: Plugin.Interface) =>
)
describe("PluginSupervisor reload", () => {
for (const selector of ["-*", "-opencode.*", "-opencode.provider.opencode"]) {
it.live(`keeps the managed policy source active despite ${selector}`, () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: [selector] })),
)
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const config = yield* Config.Service
expect(
(yield* config.entries()).some(
(entry) => entry.type === "document" && entry.info.plugins?.includes(selector),
),
).toBe(true)
const inventory = yield* plugins.list()
expect(inventory.find((plugin) => plugin.id === "opencode.provider.opencode")?.state.status).toBe("active")
if (selector !== "-opencode.provider.opencode")
expect(
inventory.some((plugin) => plugin.id === "opencode.provider.openai" && plugin.state.status === "active"),
).toBe(false)
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
}
;(
[
{ name: "on a helper-only save", helper: "nested/helper.ts", touchEntry: false },
@@ -0,0 +1,25 @@
export * as ProviderPolicyMatcher from "./opencode-policy-matcher.js"
// Shared verbatim with OpenCode packages/util/src/opencode-policy-matcher.ts.
// Keep the paired conformance check passing when changing these semantics.
export type Statement = {
readonly action: "provider.use"
readonly resource: string
readonly effect: "allow" | "deny"
}
export function match(input: string, pattern: string, windows: boolean) {
const normalized = input.replaceAll("\\", "/")
const escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
const expression = escaped.endsWith(" .*") ? escaped.slice(0, -3) + "( .*)?" : escaped
return new RegExp("^" + expression + "$", windows ? "si" : "s").test(normalized)
}
export function evaluate(statements: readonly Statement[], resource: string, windows: boolean) {
const index = statements.findLastIndex((statement) => match(resource, statement.resource, windows))
return { effect: index === -1 ? ("inherit" as const) : statements[index]!.effect, index: index === -1 ? null : index }
}
@@ -0,0 +1,146 @@
[
{
"name": "no organization override",
"rules": [],
"resource": "openai",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "last match wins",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "*"
},
{
"action": "provider.use",
"effect": "allow",
"resource": "openai"
}
],
"resource": "openai",
"unix": "allow",
"windows": "allow",
"index": 1
},
{
"name": "wildcard after exact wins",
"rules": [
{
"action": "provider.use",
"effect": "allow",
"resource": "openai"
},
{
"action": "provider.use",
"effect": "deny",
"resource": "*"
}
],
"resource": "openai",
"unix": "deny",
"windows": "deny",
"index": 1
},
{
"name": "anchored wildcard",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "company-*"
}
],
"resource": "not-company-a",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "question matches one",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "company-?"
}
],
"resource": "company-ab",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "slash normalization",
"rules": [
{
"action": "provider.use",
"effect": "allow",
"resource": "company/*"
}
],
"resource": "company\\model",
"unix": "allow",
"windows": "allow",
"index": 0
},
{
"name": "case differs by platform",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "OpenAI"
}
],
"resource": "openai",
"unix": "inherit",
"windows": "deny",
"index": 0
},
{
"name": "regex characters are literal",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "company.[ab]"
}
],
"resource": "company.a",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "trailing wildcard space",
"rules": [
{
"action": "provider.use",
"effect": "allow",
"resource": "company *"
}
],
"resource": "company",
"unix": "allow",
"windows": "allow",
"index": 0
},
{
"name": "question matches exactly one",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "comp?ny"
}
],
"resource": "company",
"unix": "deny",
"windows": "deny",
"index": 0
}
]
@@ -0,0 +1,34 @@
import { expect, it } from "bun:test"
import { Schema } from "effect"
import { ProviderPolicyMatcher } from "../src/opencode-policy-matcher.js"
const cases = Schema.decodeUnknownSync(
Schema.Array(
Schema.Struct({
name: Schema.String,
rules: Schema.Array(
Schema.Struct({
action: Schema.Literal("provider.use"),
effect: Schema.Literals(["allow", "deny"]),
resource: Schema.String,
}),
),
resource: Schema.String,
unix: Schema.Literals(["allow", "deny", "inherit"]),
windows: Schema.Literals(["allow", "deny", "inherit"]),
index: Schema.NullOr(Schema.Int),
}),
),
)(await Bun.file(new URL("./opencode-policy-cases.json", import.meta.url)).json())
for (const scenario of cases) {
it(scenario.name, () => {
for (const windows of [false, true]) {
const effect = windows ? scenario.windows : scenario.unix
expect(ProviderPolicyMatcher.evaluate(scenario.rules, scenario.resource, windows)).toEqual({
effect,
index: effect === "inherit" ? null : scenario.index,
})
}
})
}
+69 -33
View File
@@ -1,10 +1,10 @@
# Provider Policy
Status: **Implemented.**
Status: **Implemented, including paired Console managed-policy support in the working tree.** Release and deployment are separate; this spec does not name a minimum released client version.
## Purpose
Policies control whether an operation on a named resource is allowed. Statements are authored in configuration files and applied by a terminal catalog plugin.
Policies control whether an operation on a named resource is allowed. Statements come from authored configuration and, while connected to Console, an authenticated managed response. A core policy service enforces the combined result at catalog reads and model resolution.
The first policy consumer is provider availability:
@@ -24,9 +24,9 @@ A provider can be correctly configured and have valid credentials while policy s
- 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.
- Support wildcard matching for provider-ID resources.
- Support the literal `provider.use` action; other actions require separate implementation.
- Let user policy override repository policy, and organization-managed policy override both.
- Keep evaluation simple: matching statements are applied in order and the last match wins.
## Non-Goals
@@ -34,7 +34,7 @@ A provider can be correctly configured and have valid credentials while policy s
- 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.
- Policies do not pin a provider's endpoint, require gateway routing, enroll devices, sandbox arbitrary executable plugins, or cancel in-flight requests.
## Statement Shape
@@ -55,24 +55,24 @@ A provider can be correctly configured and have valid credentials while policy s
```ts
interface PolicyInfo {
effect: "allow" | "deny"
action: string
action: "provider.use"
resource: string
}
```
`ConfigPolicy` owns the statement schema. The policy plugin interprets the supported `provider.use` action after all other catalog transforms have run.
`ConfigPolicy` owns the statement schema. `ProviderPolicy` interprets it outside the removable plugin registry. Managed responses validate the complete policy strictly: unknown fields/actions/effects, empty or untrimmed resources, and resources over 256 characters are invalid. Console allows up to 100 custom statements; generated presets may have more.
## Matching
Both `action` and `resource` use opencode's existing wildcard matching behavior.
The action must be exactly `provider.use`; `provider.*` is invalid. Resources use anchored `*` (zero or more characters) and `?` (one character) wildcards. Backslashes normalize to slashes. Windows matches case-insensitively; macOS and Linux match case-sensitively. Matching retains the existing optional trailing ` *` behavior. Patterns are not regular expressions or IAM resource names.
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 |
| 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.use` | `*` | Every provider ID |
No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result.
@@ -81,23 +81,18 @@ No pattern-specific precedence exists. A specific resource does not automaticall
To evaluate an operation and resource:
1. Start with `allow`.
2. Consider every statement whose `action` and `resource` match the requested action and resource.
2. Consider every `provider.use` statement whose resource matches the provider ID.
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
)
}
const decision = ProviderPolicyMatcher.evaluate(statements, providerID, process.platform === "win32")
const allowed = decision.effect !== "deny"
```
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.
The pure matcher returns `allow`, `deny`, or `inherit`, plus the winning statement index. `inherit` permits otherwise usable providers. An active Console connection without a valid compatible managed snapshot is separately gated as unavailable; it never falls back to unrestricted provider use.
## Ordering Within One Config Document
@@ -193,7 +188,7 @@ The relative policy precedence of direct project files and `.opencode` files is
## 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.
Organization-managed policy is not ordinary authored config. Managed statements append after the reversed authored statements, before a single final evaluation. This lets an organization allow override a user deny without trying to resurrect a provider already removed by an earlier policy filter.
```text
repository policy -> user-global policy -> organization-managed policy
@@ -201,18 +196,56 @@ 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.
The built-in `opencode.provider.opencode` loader remains active under ordinary removal selectors such as `-*`, `-opencode.*`, and `-opencode.provider.opencode`. The policy service is not a removable plugin, and policy mutation is not exposed through the plugin context API. Late catalog transforms still pass through policy checks when read.
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.
### Console contract
The authenticated `GET /api/v2/config` response includes providers and required managed fields:
```json
{
"providers": {},
"experimental": { "policies": [] },
"managedPolicy": {
"schemaVersion": 1,
"workspaceID": "org_example",
"revision": 7
}
}
```
The loader validates the policy without silently dropping unsupported fields. Trust comes from the authenticated loader and active connection, not the presence of a descriptor in a local file. Policy and provider data are applied from the same validated response. The descriptor identifies the saved authoring revision; generated preset output can also change when Console connections change. Refresh compares the whole snapshot, not only the revision.
Console offers default access, only workspace provider IDs, and custom ordered rules. Default access denies `opencode` when Console emits no connection with that ID. Managed-only denies `*` then allows each exact emitted ID. Custom `[]` explicitly removes organization overrides. Custom mode replaces the legacy v1 managed-only setting; it is not converted to v1 rules.
An org that has never published policies still receives a valid revision-zero descriptor and rules derived from its existing provider settings, even with Console's publication flag disabled. The client accepts defaults without requiring administrator setup. The current client nevertheless requires a valid response for every connected org: a cold-start outage or old server blocks even a policy-free org because its policy state is not yet known. There is no persisted opt-in exemption before that first fetch.
### Connection and failure behavior
- Disconnected: apply authored local rules.
- Connected before a valid compatible snapshot: block all normal provider/model resolution, including local providers, with an actionable policy-unavailable error.
- Valid response: apply managed rules after local rules.
- Same-identity transient refresh failure (network, timeout, 429, or 5xx): retain the last validated in-memory snapshot and mark it stale internally.
- Missing, malformed, unsupported, wrong-workspace, or authorization-rejected response: mark policy unavailable. Failure never means an empty policy.
- Server, workspace, or service-key switch: require a valid snapshot for the new identity. Never carry the previous identity's rules forward.
- Valid explicit `[]`: clear organization overrides; local rules remain.
Binding includes connection identity, server, selected organization metadata, and the service key where applicable. The response workspace must match organization metadata when present. OAuth access-token refresh does not change this identity. The raw binding stays internal and must never be logged or exposed.
The existing loader polls every ten minutes and refreshes on connection changes. There is no durable offline cache, fleet acknowledgment, or in-flight revocation. Disconnecting Console leaves the scope of this feature.
Deploy Console's required response contract first, release the compatible client next, then enable Console's `opencode-policies` publication flag. A new client connected to an old server intentionally blocks. Older clients may ignore the fields; do not claim enforcement for them. Turning off publication does not remove an already published policy.
## Interaction With Provider Configuration
```jsonc
{
"providers": {
"company-ai": {
"endpoint": {
"type": "openai/responses",
"url": "https://ai.company.example/v1/responses",
},
"package": "@opencode/ai/providers/openai",
"settings": { "baseURL": "https://ai.company.example/v1" },
},
},
"experimental": {
@@ -224,7 +257,7 @@ Provider policy is not a full sandbox for executable plugins. A denied provider
}
```
The provider entry configures `company-ai`; the policy statements make it the only provider permitted for use.
The provider entry configures `company-ai`; the policy statements make that ID the only provider permitted for use. Credentials and models are still required. Local provider overlays may change an allowed ID's package, endpoint, or other transport fields; a provider-ID allowlist is not endpoint enforcement.
Provider policy applies regardless of how a provider becomes known or usable, including:
@@ -236,16 +269,19 @@ Provider policy applies regardless of how a provider becomes known or usable, in
## Applying Provider Policy
Provider records and model overrides are assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered.
Provider records and model overrides remain in the underlying catalog. Public reads check policy so later provider loading cannot bypass filtering, while a later valid allow can expose an otherwise usable provider again.
Flow:
1. Build provider/model catalog entries.
2. Apply configured provider and model overrides.
3. Run the terminal config policy transform.
4. Remove providers denied by the final matching `provider.use` statement.
3. Read one policy snapshot containing reversed authored rules followed by managed rules.
4. Filter all public provider/model catalog views using the final decision.
5. Assert policy again at model resolution, including resolution of a previously selected model value.
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog.
Config updates publish catalog-updated events. Managed refresh updates the policy snapshot before rebuilding catalog/search projections. Session errors distinguish denied/unavailable policy in their messages while using the existing error protocol.
The pure matcher and fixtures are duplicated in Console's shared package and this repository's util package. Console's `scripts/check-opencode-policy-conformance.ts` verifies byte-for-byte equality. Tests cover precedence, platform matching, catalog views, previously selected model resolution, loader protection, failure transitions, and identity changes. An opt-in paired integration test consumes a policy published through Console's real browser/API/database flow. These checks do not establish deployed fleet behavior or successful billable provider requests.
## Legacy Migration