Compare commits

...
Author SHA1 Message Date
Adam 27a69a9b2e feat(core): enforce Console-managed policies
The Console returns experimental.policies from /api/v2/config, but the
client decoded only providers and websearch, so nothing was enforced.

- Decode the statements and hold them in a process-global ManagedPolicy
  service the Console plugin writes and the policy plugin reads.
- Evaluate organization statements after every reversed authored
  document so they have final authority; name the organization in the
  denial message.
- Keep the last config for the same connection when a fetch or token
  refresh fails, so an outage cannot lift organization policy while
  personal credentials keep working. Switch, disconnect, and 404 still
  replace it.
- Ignore plugin removals for opencode.config.policy and
  opencode.provider.opencode so a repository cannot disable enforcement.
- Cover the permission action in tests and the spec, regenerate the
  OpenAPI enum, and add the V2 policies docs page.
2026-09-17 18:40:35 -05:00
21 changed files with 785 additions and 71 deletions
+30 -14
View File
@@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin"
import { Document } from "@opencode/schema/config"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ManagedPolicy } from "../../managed-policy.js"
import { Wildcard } from "../../util/wildcard.js"
import { ConfigEntryObserver } from "./entry-observer.js"
@@ -11,16 +12,30 @@ export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const managed = yield* ManagedPolicy.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
const policies = () =>
loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
// Authored documents reverse so user-global policy outranks repository policy; organization statements
// from the connected Console follow every authored one and have the final say.
const policies = () => {
const organization = managed.current()
return [
...loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
.map((policy) => ({ ...policy, message: "Blocked by configuration policy" })),
...organization.statements.map((policy) => ({
...policy,
message: organization.organization
? `Blocked by ${organization.organization}'s policy`
: "Blocked by your organization's policy",
})),
]
}
yield* ctx.provider.transform((providers) => {
// User-global policy takes priority over policy authored by a repository.
const current = policies()
for (const record of providers.list()) {
const policy = policies().findLast(
const policy = current.findLast(
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
)
if (policy?.effect === "deny") providers.remove(record.provider.id)
@@ -29,16 +44,17 @@ export const Plugin = define({
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => {
const current = policies()
const denied = event.resources.some((resource) => {
const policy = current.findLast(
(policy) =>
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
const denied = event.resources
.map((resource) =>
current.findLast(
(policy) =>
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
),
)
return policy?.effect === "deny"
})
.find((policy) => policy?.effect === "deny")
if (!denied) return
event.effect = "deny"
event.message = "Blocked by configuration policy"
event.message = denied.message
}),
)
}),
+34
View File
@@ -0,0 +1,34 @@
export * as ManagedPolicy from "./managed-policy.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
/** Policy statements the connected OpenCode Console compiled for whoever it authenticated. */
export interface State {
readonly statements: ReadonlyArray<ConfigPolicy.Info>
/** Organization name for denial messages, when the connection knows it. */
readonly organization?: string
}
export interface Interface {
/** Synchronous so catalog transforms can consult the statements while they run. */
readonly current: () => State
/** Replaces the whole state; statements never merge across connections. */
readonly set: (state: State) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ManagedPolicy") {}
const layer = Layer.sync(Service, () => {
const state: { current: State } = { current: { statements: [] } }
return Service.of({
current: () => state.current,
set: (next) =>
Effect.sync(() => {
state.current = next
}),
})
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+8
View File
@@ -48,6 +48,7 @@ import { Integration } from "../integration.js"
import { Job } from "../job.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { ManagedPolicy } from "../managed-policy.js"
import { ModelsDev } from "../models-dev.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode/util/npm"
@@ -89,6 +90,7 @@ import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
import { ProviderPlugins } from "./provider.js"
import { OpencodePlugin } from "./provider/opencode.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
@@ -121,6 +123,7 @@ const services = [
Job.Service,
KV.Service,
Location.Service,
ManagedPolicy.Service,
ModelsDev.Service,
Mcp.Service,
Npm.Service,
@@ -172,6 +175,7 @@ export const requirements = LayerNode.group([
Job.node,
KV.node,
Location.node,
ManagedPolicy.node,
ModelsDev.node,
Mcp.node,
Npm.node,
@@ -252,6 +256,10 @@ const post = [
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
// Repository config must not switch off policy enforcement or the Console connection that delivers
// organization statements, so plugin remove operations skip these IDs.
export const guarded: ReadonlySet<string> = new Set([OpencodePlugin.id, ConfigPolicyPlugin.Plugin.id])
export const list = Effect.fn("PluginInternal.list")(function* () {
// Capture only services; activation supplies the child Scope and batching context.
const context = Context.pick(...services)(yield* Effect.context<Requirements>())
+39 -12
View File
@@ -6,8 +6,11 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { ManagedPolicy } from "../../managed-policy.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
@@ -19,6 +22,10 @@ const RemoteResponse = Schema.Struct({
websearch: Schema.Struct({
providerID: WebSearch.ID,
}).pipe(Schema.optional),
// Organization policy compiled for the authenticated caller; omitted when there is none.
experimental: Schema.Struct({
policies: Schema.Array(ConfigPolicy.Info).pipe(Schema.optional),
}).pipe(Schema.optional),
})
const Device = Schema.Struct({
device_code: Schema.String,
@@ -111,32 +118,50 @@ 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 | ManagedPolicy.Service | Scope.Scope>({
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const managed = yield* ManagedPolicy.Service
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
config: typeof RemoteResponse.Type | undefined
connection: ActiveConnection
} = { config: undefined, connection: undefined }
organization: string | undefined
} = { config: undefined, connection: undefined, organization: undefined }
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 config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
if (!connection) return { config: undefined, connection, organization: undefined }
return yield* ctx.integration.connection.resolve(connection).pipe(
Effect.flatMap((credential) => {
if (!credential) return Effect.succeed({ config: undefined, connection, organization: undefined })
return fetchConfig(http, credential).pipe(
Effect.map((config) => ({
config,
connection,
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
})),
)
: undefined
return { config, connection }
}),
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
// A load that fails for the connection already in place keeps its last config: dropping it
// would lift organization policy while personal credentials keep working.
Effect.as(
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
? { config: snapshot.config, connection, organization: snapshot.organization }
: { config: undefined, connection, organization: undefined },
),
),
),
)
})
// Statements ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too.
const publish = (next: typeof snapshot) =>
managed.set({ statements: next.config?.experimental?.policies ?? [], organization: next.organization })
yield* ctx.integration.transform((editor) => {
editor.update("opencode", (integration) => {
@@ -147,6 +172,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
})
snapshot = yield* load()
yield* publish(snapshot)
yield* ctx.provider.transform((providers) => {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
const source = providers.get(item.canonical ?? providerID)
@@ -309,6 +335,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* publish(next)
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
+1 -1
View File
@@ -39,7 +39,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.filter((plugin) => matches(operation.target, plugin.id) && !PluginInternal.guarded.has(plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
continue
}
+144 -20
View File
@@ -1,11 +1,16 @@
import { describe, expect } from "bun:test"
import { Document, Event, Info, type Entry } from "@opencode/schema/config"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { Permission } from "@opencode/schema/permission"
import { Config } from "@opencode/core/config"
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
import { Bus } from "@opencode/core/bus"
import { ManagedPolicy } from "@opencode/core/managed-policy"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { PluginHost } from "@opencode/core/plugin/host"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -13,15 +18,18 @@ import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Info)
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
new Document({
type: "document",
info: decode({
experimental: {
policies: items.map((item) => ({ action: "provider.use", ...item })),
},
}),
})
const document = (...policies: ConfigPolicy.Info[]) =>
new Document({ type: "document", info: decode({ experimental: { policies } }) })
const provider = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({
action: "provider.use",
resource,
effect,
})
const permission = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({
action: "permission",
resource,
effect,
})
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const plugin = yield* Plugin.Service
@@ -29,6 +37,17 @@ const addPlugin = Effect.fn(function* (entries: Entry[]) {
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
})
const evaluate = Effect.fn(function* (action: string, resources: string[], effect: Permission.Effect = "allow") {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: Session.ID.make("ses_policy"),
action,
resources,
effect,
})
return { effect: event.effect, message: event.message }
})
describe("ConfigPolicyPlugin.Plugin", () => {
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
Effect.gen(function* () {
@@ -39,11 +58,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
catalog.update(Provider.ID.make("company-internal"), () => {})
})
yield* addPlugin([
policies(
{ effect: "deny", resource: "*" },
{ effect: "allow", resource: "anthropic" },
{ effect: "allow", resource: "company-*" },
),
document(provider("deny", "*"), provider("allow", "anthropic"), provider("allow", "company-*")),
])
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
@@ -56,10 +71,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Provider.Service
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
yield* addPlugin([
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
yield* addPlugin([document(provider("deny", "openai")), document(provider("allow", "openai"))])
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
}),
@@ -76,10 +88,122 @@ describe("ConfigPolicyPlugin.Plugin", () => {
yield* ConfigPolicyPlugin.Plugin.effect(host)
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
yield* test.setEntries([document(provider("allow", "openai"))])
yield* bus.publish(Event.Updated, {})
yield* waitUntil(catalog.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
}).pipe(Effect.provide(Config.testLayer([document(provider("deny", "openai"))]))),
)
it.effect("denies permissions matched as action:resource", () =>
Effect.gen(function* () {
yield* addPlugin([document(permission("deny", "shell:git push *"))])
expect(yield* evaluate("shell", ["git push"])).toEqual({
effect: "deny",
message: "Blocked by configuration policy",
})
expect(yield* evaluate("shell", ["git push origin main"])).toEqual({
effect: "deny",
message: "Blocked by configuration policy",
})
// Compound commands check several resources; any denied resource denies the operation.
expect((yield* evaluate("shell", ["git status", "git push"])).effect).toBe("deny")
expect(yield* evaluate("shell", ["git status"])).toEqual({ effect: "allow", message: undefined })
expect(yield* evaluate("edit", ["git push"])).toEqual({ effect: "allow", message: undefined })
}),
)
it.effect("turns an ask into a deny but never grants", () =>
Effect.gen(function* () {
yield* addPlugin([document(permission("deny", "webfetch:*"), permission("allow", "shell:*"))])
expect((yield* evaluate("webfetch", ["https://example.com"], "ask")).effect).toBe("deny")
expect((yield* evaluate("shell", ["ls"], "ask")).effect).toBe("ask")
}),
)
it.effect("lets a later allow lift an earlier broad deny", () =>
Effect.gen(function* () {
yield* addPlugin([document(permission("deny", "shell:*"), permission("allow", "shell:git status *"))])
expect((yield* evaluate("shell", ["git status --short"])).effect).toBe("allow")
expect((yield* evaluate("shell", ["rm -rf /"])).effect).toBe("deny")
}),
)
it.effect("denies every permission with a bare wildcard", () =>
Effect.gen(function* () {
yield* addPlugin([document(permission("deny", "*"))])
expect((yield* evaluate("question", ["*"])).effect).toBe("deny")
expect((yield* evaluate("read", ["/tmp/notes.txt"])).effect).toBe("deny")
expect((yield* evaluate("github_delete_repository", ["*"])).effect).toBe("deny")
}),
)
it.effect("evaluates organization provider statements after every authored document", () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const managed = yield* ManagedPolicy.Service
yield* catalog.transform((catalog) => {
catalog.update(Provider.ID.openai, () => {})
catalog.update(Provider.ID.anthropic, () => {})
catalog.update(Provider.ID.opencode, () => {})
})
yield* managed.set({ statements: [provider("deny", "*"), provider("allow", "opencode")] })
yield* addPlugin([document(provider("allow", "anthropic"))])
expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined()
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined()
}),
)
it.effect("lets an organization allow restore a provider denied by the user", () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const managed = yield* ManagedPolicy.Service
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
yield* managed.set({ statements: [provider("allow", "openai")] })
yield* addPlugin([document(provider("deny", "openai"))])
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
}),
)
it.effect("names the organization when its permission statement decides", () =>
Effect.gen(function* () {
const managed = yield* ManagedPolicy.Service
yield* managed.set({ statements: [permission("deny", "shell:sudo *")], organization: "Acme" })
yield* addPlugin([document(permission("allow", "shell:*"))])
expect(yield* evaluate("shell", ["sudo ls"])).toEqual({ effect: "deny", message: "Blocked by Acme's policy" })
expect((yield* evaluate("shell", ["ls"])).effect).toBe("allow")
yield* managed.set({ statements: [permission("deny", "shell:sudo *")] })
expect(yield* evaluate("shell", ["sudo ls"])).toEqual({
effect: "deny",
message: "Blocked by your organization's policy",
})
}),
)
it.effect("rebuilds the catalog from replaced organization statements", () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const managed = yield* ManagedPolicy.Service
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
yield* addPlugin([])
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
yield* managed.set({ statements: [provider("deny", "openai")] })
yield* catalog.reload()
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
yield* managed.set({ statements: [] })
yield* catalog.reload()
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
}),
)
})
+2
View File
@@ -14,6 +14,7 @@ import { Generate } from "@opencode/core/generate"
import { Integration } from "@opencode/core/integration"
import { KV } from "@opencode/core/kv"
import { Location } from "@opencode/core/location"
import { ManagedPolicy } from "@opencode/core/managed-policy"
import { Mcp } from "@opencode/core/mcp/index"
import { Model } from "@opencode/core/model"
import { Npm } from "@opencode/util/npm"
@@ -85,6 +86,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
Command.node,
Integration.node,
KV.node,
ManagedPolicy.node,
Mcp.node,
Session.node,
PersistentPty.node,
@@ -1,12 +1,16 @@
import { describe, expect } from "bun:test"
import { LLM } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { Money } from "@opencode/schema/money"
import { Effect, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "@opencode/core/config"
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { ManagedPolicy } from "@opencode/core/managed-policy"
import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
@@ -619,6 +623,158 @@ describe("OpencodePlugin", () => {
),
)
it.effect("enforces organization policy statements from the Console", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () =>
Response.json({
providers: { opencode: {} },
experimental: {
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "opencode", effect: "allow" },
{ action: "permission", resource: "shell:sudo *", effect: "deny", audience: "ignored" },
],
unknown: true,
},
}),
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Provider.Service
const managed = yield* ManagedPolicy.Service
const plugins = yield* Plugin.Service
yield* catalog.transform((catalog) => catalog.update(Provider.ID.anthropic, () => {}))
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: server.url.origin, orgID: "org_test", orgName: "Acme" },
}),
})
const host = yield* PluginHost.make(plugins)
yield* OpencodePlugin.effect(host)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer([])))
expect(managed.current()).toEqual({
statements: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "opencode", effect: "allow" },
{ action: "permission", resource: "shell:sudo *", effect: "deny" },
],
organization: "Acme",
})
expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined()
expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined()
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect("keeps policy statements bound to the connected Console account", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state: { status: number; policies: Record<string, unknown[]>; requests: number } = {
status: 200,
policies: {},
requests: 0,
}
const server = Bun.serve({
port: 0,
fetch: (request) => {
state.requests++
if (state.status !== 200) return new Response("Unavailable", { status: state.status })
const policies = state.policies[request.headers.get("x-org-id") ?? ""]
return Response.json({ providers: {}, ...(policies ? { experimental: { policies } } : {}) })
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
const managed = yield* ManagedPolicy.Service
const rebuilds = { count: 0 }
const account = (orgID: string, orgName: string) =>
credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: orgID,
metadata: { server: server.url.origin, orgID, orgName },
}),
})
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
const env: ConfigPolicy.Info = { action: "permission", resource: "edit:*.env", effect: "deny" }
yield* providers.transform(() => {
rebuilds.count++
})
const alpha = yield* account("org_alpha", "Alpha")
yield* addPlugin()
yield* drain
const initial = rebuilds.count
expect(state.requests).toBe(1)
expect(managed.current()).toEqual({ statements: [], organization: "Alpha" })
state.policies.org_alpha = [sudo]
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(2)
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
expect(rebuilds.count).toBe(initial + 1)
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(3)
expect(rebuilds.count).toBe(initial + 1)
// An outage for the same connection keeps the last statements instead of lifting them.
state.status = 503
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(4)
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
expect(rebuilds.count).toBe(initial + 1)
state.status = 200
state.policies.org_beta = [env]
const beta = yield* account("org_beta", "Beta")
yield* eventually(
Effect.sync(() => managed.current()),
(current) => current.organization === "Beta",
)
expect(managed.current()).toEqual({ statements: [env], organization: "Beta" })
state.status = 404
yield* TestClock.adjust("1 minute")
yield* drain
expect(managed.current()).toEqual({ statements: [], organization: "Beta" })
state.status = 200
yield* credentials.remove(beta.id)
yield* eventually(
Effect.sync(() => managed.current()),
(current) => current.organization === "Alpha",
)
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
yield* credentials.remove(alpha.id)
yield* eventually(
Effect.sync(() => managed.current()),
(current) => current.organization === undefined,
)
expect(managed.current()).toEqual({ statements: [], organization: undefined })
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
+32 -2
View File
@@ -32,14 +32,14 @@ const greeter = (command: string, plugin: string = id) =>
})
// Every supervisor activation scans the config plugin operations once, so counting scans counts activations.
const source = { activations: 0 }
const source: { activations: number; operations: ConfigPluginSource.Operation[] } = { activations: 0, operations: [] }
const sourceLayer = Layer.succeed(
ConfigPluginSource.Service,
ConfigPluginSource.Service.of({
operations: () =>
Effect.sync(() => {
source.activations++
return []
return source.operations
}),
changes: () => Stream.never,
}),
@@ -202,6 +202,36 @@ describe("PluginSupervisor", () => {
}),
)
for (const targets of [["opencode.config.policy", "opencode.provider.opencode", "opencode.config.agent"], ["*"]]) {
it.effect(`keeps policy enforcement and the Console connection through plugin removals: ${targets}`, () =>
Effect.gen(function* () {
source.operations = targets.map((target) => ({ type: "remove", target }))
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
const inventory = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
return yield* plugins.list()
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
const status = (id: string) => inventory.find((plugin) => plugin.id === id)?.state.status
expect(status("opencode.config.policy")).toBe("active")
expect(status("opencode.provider.opencode")).toBe("active")
// Removals still apply to everything else, including instance and builtin plugins.
expect(status("opencode.config.agent")).toBeUndefined()
expect(inventory.some((plugin) => plugin.id === id)).toBe(targets[0] !== "*")
}).pipe(
Effect.ensuring(
Effect.sync(() => {
source.operations = []
}),
),
),
)
}
it.effect("coalesces a burst of reload triggers after the initial generation into one activation", () =>
Effect.gen(function* () {
source.activations = 0
+1 -1
View File
@@ -13161,7 +13161,7 @@
"properties": {
"action": {
"type": "string",
"enum": ["provider.use"]
"enum": ["provider.use", "permission"]
},
"resource": {
"type": "string"
+1 -1
View File
@@ -13161,7 +13161,7 @@
"properties": {
"action": {
"type": "string",
"enum": ["provider.use"]
"enum": ["provider.use", "permission"]
},
"resource": {
"type": "string"
+1 -1
View File
@@ -13161,7 +13161,7 @@
"properties": {
"action": {
"type": "string",
"enum": ["provider.use"]
"enum": ["provider.use", "permission"]
},
"resource": {
"type": "string"
+20
View File
@@ -179,6 +179,26 @@ matching resource.
See the [permissions guide](/permissions) for rule matching and available actions.
### Policies
Allow or deny use of a provider, or hard-deny a permission check, with ordered
statements that broader configuration can override.
```jsonc
{
"experimental": {
"policies": [
{ "action": "provider.use", "resource": "*", "effect": "deny" },
{ "action": "provider.use", "resource": "anthropic", "effect": "allow" },
{ "action": "permission", "resource": "shell:git push *", "effect": "deny" },
],
},
}
```
See the [policies guide](/policies) for matching, precedence across configuration
files, and Console-managed policy.
### Agents
Override built-in agents or define specialized agents with their own model,
@@ -11,3 +11,10 @@ using OpenCode particularly as a team
- Deploy team wide policies to control OpenCode behavior
- Web search
- OpenCode Go, $10 subscription for open source model access
## Policies
Providers and Tools rules from the Policies page apply to every OpenCode V2 client
connected to the workspace. They are delivered as [policy](/policies#console)
statements with final authority over the member's own configuration and take effect
within about a minute.
+2 -1
View File
@@ -415,7 +415,8 @@ Most fields that keep the same shape, including `shell`, `model`, `default_agent
V2 accepts and preserves `lsp` configuration, but it does not run language servers, expose LSP tools, or produce LSP
diagnostics. Replace workflows that depend on those capabilities with the project's lint, typecheck, or compiler commands.
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported through
[policies](/policies):
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
- `disabled_providers` becomes internal deny policies for the listed providers.
@@ -277,3 +277,19 @@ Review broad approvals and remove those no longer needed.
Clients may attach feedback when rejecting. Non-interactive clients must decide
how to handle approval requests; configured `deny` rules always remain enforced.
## Policies
A [policy](/policies) can hard-deny a permission check after these rules and saved
approvals run. It turns `allow` or `ask` into `deny` and never grants access.
```jsonc
{
"experimental": {
"policies": [{ "action": "permission", "resource": "shell:sudo *", "effect": "deny" }],
},
}
```
Global and Console-managed policies override project configuration, which is how an
organization blocks a command that a repository would allow.
@@ -76,6 +76,11 @@ use `.*` to match an ID prefix. A later ID re-enables a plugin.
}
```
Two built-in plugins ignore removals so that a repository cannot switch off
[policy](/policies) enforcement: `opencode.config.policy` and
`opencode.provider.opencode`, the Console connection that delivers organization
policy.
## Manage
Install, list, check, update, or remove global package plugins with the CLI.
+215
View File
@@ -0,0 +1,215 @@
---
title: "Policies"
description: "Allow or deny OpenCode actions on named resources, with organization policy taking final authority."
---
Policies decide whether OpenCode may perform an action on a named resource. They are
authored under `experimental.policies` and can also be delivered by a connected
[OpenCode Console](/console) workspace.
Policies are separate from [permissions](/permissions). Permissions are per-agent tool
rules that can `allow`, `deny`, or `ask`. Policies are binary, never prompt, and only
ever tighten what permissions and providers would otherwise allow.
## Configure
Deny one provider everywhere by adding a statement to `opencode.jsonc`:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"experimental": {
"policies": [{ "action": "provider.use", "resource": "openai", "effect": "deny" }],
},
}
```
A denied provider disappears from the catalog and model selection even when it has
valid credentials.
## Statements
Each statement has three fields:
| Field | Values | Meaning |
| ---------- | ---------------------------- | ---------------------------------------------------- |
| `action` | `provider.use`, `permission` | The operation being controlled |
| `resource` | string or wildcard pattern | What the statement applies to; depends on the action |
| `effect` | `allow`, `deny` | The decision when this statement matches |
Statements that fail validation are dropped with a warning in the server log and
the rest of the list still applies. Check the log after editing a `deny`.
## Matching
Resources use the same whole-value wildcards as permissions: `*` matches zero or
more characters and `?` matches one.
```jsonc
{
"experimental": {
"policies": [{ "action": "provider.use", "resource": "company-*", "effect": "deny" }],
},
}
```
This denies `company-us` and `company-eu`. A pattern ending in ` *` also matches the
value without arguments, so `shell:git push *` covers `git push` on its own.
## Order
When several statements match, the last one wins. There is no specificity rule, so
put broad statements first and exceptions after them. If nothing matches, the
action is allowed.
```jsonc
{
"experimental": {
"policies": [
{ "action": "provider.use", "resource": "*", "effect": "deny" },
{ "action": "provider.use", "resource": "anthropic", "effect": "allow" },
],
},
}
```
Only Anthropic remains available.
## Providers
`provider.use` controls whether a provider is usable at all. The resource is the
provider ID: `anthropic`, `openai`, `opencode`, or a custom ID from
[`providers`](/providers).
```jsonc
{
"providers": {
"company-ai": {
"endpoint": { "type": "openai/responses", "url": "https://ai.company.example/v1/responses" },
},
},
"experimental": {
"policies": [
{ "action": "provider.use", "resource": "*", "effect": "deny" },
{ "action": "provider.use", "resource": "company-ai", "effect": "allow" },
],
},
}
```
The `providers` entry configures the endpoint; the statements make it the only
provider OpenCode will use. Policy applies however a provider became known: catalog
data, environment variables, saved accounts, built-in plugins, or configuration.
Use `provider.use` instead of the V1 `enabled_providers` and `disabled_providers`
lists. V1 files still load; see
[Migrate from V1](/migrate-v1#supported-fields-without-direct-native-equivalents).
## Permissions
`permission` statements hard-deny a [permission](/permissions) check. The resource is
`<action>:<value>`, matched against every resource the tool checks.
```jsonc
{
"experimental": {
"policies": [
{ "action": "permission", "resource": "shell:git push *", "effect": "deny" },
{ "action": "permission", "resource": "edit:*.env", "effect": "deny" },
{ "action": "permission", "resource": "webfetch:*", "effect": "deny" },
],
},
}
```
A denied check fails with `Blocked by configuration policy` instead of prompting. It
applies after agent rules and saved approvals, so it overrides an `ask` and an
"Allow always" approval alike.
| Pattern | Blocks |
| ---------------------------- | ------------------------------------------- |
| `shell:*` | Every shell command |
| `read:*/.ssh/*` | Reading SSH material |
| `external_directory:*` | Any access outside the Location or worktree |
| `subagent:general` | Launching one subagent |
| `github_delete_repository:*` | One MCP tool |
| `*` | Everything not already denied |
A `permission` statement with `allow` never grants access. It only lifts an earlier
broader `deny`, after which the agent's own rules decide.
```jsonc
{
"experimental": {
"policies": [
{ "action": "permission", "resource": "shell:*", "effect": "deny" },
{ "action": "permission", "resource": "shell:git status *", "effect": "allow" },
],
},
}
```
`git status` falls back to the agent's `shell` rule; every other command is blocked.
## Precedence
Ordinary settings let the nearest configuration win. Policies reverse that: statements
from broader configuration are evaluated later, so they override narrower ones.
| Authority | Source |
| ----------- | --------------------------------------------------------------- |
| 1 (highest) | Connected OpenCode Console workspace |
| 2 | Global `~/.config/opencode/opencode.json(c)` |
| 3 | Direct `opencode.json(c)`; outer directories beat inner ones |
| 4 (lowest) | `.opencode/opencode.json(c)`; outer directories beat inner ones |
A repository cannot re-enable a provider you deny globally:
```jsonc title="~/.config/opencode/opencode.jsonc"
{
"experimental": {
"policies": [{ "action": "provider.use", "resource": "openai", "effect": "deny" }],
},
}
```
```jsonc title="project/opencode.jsonc"
{
"experimental": {
"policies": [{ "action": "provider.use", "resource": "openai", "effect": "allow" }],
},
}
```
`openai` stays denied. Within one file, statements keep their written order.
## Console
A workspace on the [OpenCode Console](/console) compiles its Providers and Tools
policies into statements and returns them with the workspace configuration. OpenCode
appends them after every authored statement, so they have the final say: an
organization `deny` cannot be lifted by a repository or user `allow`, and an
organization `allow` lifts a lower-authority `deny`.
Denials from these statements name the workspace:
```text
Blocked by Acme's policy
```
Statements follow the connected account.
| Event | Result |
| --------------------------- | -------------------------------------------------- |
| Console policy changes | Applied on the next fetch, about once a minute |
| Switch to another workspace | Statements replaced with that workspace's |
| Disconnect | Statements cleared |
| Console unreachable | Last statements for that connection stay in effect |
The Console plugin and the policy plugin cannot be disabled from `plugins`; see
[plugin control](/plugins#control).
<Callout type="note">
Policies do not configure endpoints or credentials, and they cannot make an unusable provider usable. They also do not
sandbox plugin code: a denied provider is blocked on the normal provider and model path.
</Callout>
+1
View File
@@ -49,6 +49,7 @@ export const docsSections: DocsSection[] = [
{ title: "Tools", slug: "tools" },
{ title: "MCP servers", slug: "mcp-servers" },
{ title: "Permissions", slug: "permissions" },
{ title: "Policies", slug: "policies" },
{ title: "Instructions", slug: "instructions" },
{ title: "Sharing", slug: "sharing" },
{ title: "Warming", slug: "warming" },
+4 -4
View File
@@ -26,10 +26,10 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ
## Decision Records
| Document | Status | Job |
| ---------------------------------------------- | ------------------------ | ------------------------------------------------------------------------ |
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
| [Provider policy](./provider-policy.md) | Accepted and implemented | Record provider authorization independently from provider configuration. |
| Document | Status | Job |
| ---------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------- |
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
| [Provider policy](./provider-policy.md) | Accepted and implemented | Allow or deny provider use and permission checks, with Console-managed statements having final authority. |
## Historical Context
+66 -14
View File
@@ -4,13 +4,16 @@ Status: **Implemented.**
## 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 are authored in configuration files or delivered by the connected OpenCode Console, and applied by a terminal plugin.
The first policy consumer is provider availability:
Two consumers exist:
```text
action: provider.use
resource: provider ID, such as openai or company-ai
action: permission
resource: <permission action>:<resource>, such as shell:sudo * or edit:*.env
```
Provider configuration and provider policy remain separate:
@@ -34,7 +37,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.
- A `permission` statement never grants access; permissions and saved approvals still decide `allow` versus `ask`.
## Statement Shape
@@ -55,12 +58,12 @@ A provider can be correctly configured and have valid credentials while policy s
```ts
interface PolicyInfo {
effect: "allow" | "deny"
action: string
action: "provider.use" | "permission"
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; `action` is a closed set and a statement with any other value is dropped during normalization with a diagnostic. The policy plugin interprets `provider.use` after all other catalog transforms have run and `permission` after every other permission evaluation hook.
## Matching
@@ -68,11 +71,12 @@ 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 |
| 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` |
| `permission` | `shell:git push *` | The `shell` permission for `git push` with or without arguments |
| `permission` | `*` | Every permission check on every resource |
No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result.
@@ -193,16 +197,64 @@ 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 are appended after the reversed authored statements so they have final authority: an organization `deny` cannot be lifted by a repository or user `allow`, and an organization `allow` lifts a lower-authority `deny`.
```text
repository policy -> user-global policy -> organization-managed policy
```
### Delivery
The OpenCode Console compiles a workspace's Providers and Tools policies into statements for the authenticated caller and returns them from `GET /api/v2/config` alongside managed providers:
```jsonc
{
"providers": { "opencode": {} },
"experimental": {
"policies": [
{ "action": "provider.use", "resource": "*", "effect": "deny" },
{ "action": "provider.use", "resource": "opencode", "effect": "allow" },
{ "action": "permission", "resource": "shell:sudo *", "effect": "deny" },
],
},
}
```
- `experimental` is omitted when the caller has no statements; omission and an empty array are equivalent.
- The list is per caller and its order is significant. The client stores it exactly as received; it never reorders, dedupes, or normalizes statements.
- `ManagedPolicy` (`packages/core/src/managed-policy.ts`) is the process-global home for the current statements and the organization name. The Console plugin (`opencode.provider.opencode`) writes it whenever its config snapshot is applied; the policy plugin reads it synchronously when evaluating.
- Statements ride on the Console plugin's snapshot, so they follow the connection: a credential switch replaces them, and a disconnect or a 404 from the Console clears them. Statements from different connections never merge.
- Freshness is the snapshot's freshness: the next poll (about one minute) or the next credential switch.
### Failure
A config fetch or credential refresh that fails for the connection already in place keeps that connection's last config, providers and statements alike, and logs a warning. Dropping the config would fail closed for managed providers but open for policy, because a member's personal credentials keep working while the organization's restrictions vanish. A disconnect, a credential switch, or a 404 still replaces the snapshot. There is no durable offline cache.
### Messages
When the deciding `permission` statement is organization-managed, the denial reads `Blocked by <organization>'s policy`, or `Blocked by your organization's policy` when the connection has no organization name. Authored statements produce `Blocked by configuration policy`.
### Protection
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.
Plugin `remove` operations in config ignore `opencode.config.policy` and `opencode.provider.opencode`, whatever the selector (`-*`, `-opencode.*`, or the exact ID). Otherwise a repository could switch off enforcement or the fetch that delivers organization statements.
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.
## Permission Policy
`permission` statements run in the `permission.evaluate` hook after agent and session rules, saved approvals, and every other plugin's hook. For each resource the tool checks, the string `<action>:<resource>` is matched against the statement resource; if the last matching statement for any resource is `deny`, the evaluation becomes `deny` with the message above.
```text
permission / shell:sudo ls -> deny (statement shell:sudo *)
permission / shell:git status -> unchanged: the agent's rules decide allow or ask
```
- A configured `deny` from agent or session rules already denies before the hook runs.
- A statement `deny` overrides `allow` and `ask`, including saved "Allow always" approvals.
- A statement `allow` never grants; it only cancels an earlier, broader statement `deny`.
## Interaction With Provider Configuration
```jsonc
@@ -240,12 +292,12 @@ Provider records and model overrides are assembled before checking provider poli
Flow:
1. Build provider/model catalog entries.
1. Build provider/model catalog entries, including providers managed by the connected Console.
2. Apply configured provider and model overrides.
3. Run the terminal config policy transform.
3. Run the terminal config policy transform over the reversed authored statements followed by the organization-managed statements.
4. Remove providers denied by the final matching `provider.use` statement.
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog.
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog. A changed Console snapshot rebuilds the catalog through the Console plugin's own reload, which re-runs the terminal transform.
## Legacy Migration