mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 07:16:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27a69a9b2e |
@@ -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
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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: [] })
|
||||
@@ -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>())
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -13161,7 +13161,7 @@
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["provider.use"]
|
||||
"enum": ["provider.use", "permission"]
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
|
||||
@@ -13161,7 +13161,7 @@
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["provider.use"]
|
||||
"enum": ["provider.use", "permission"]
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
|
||||
@@ -13161,7 +13161,7 @@
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["provider.use"]
|
||||
"enum": ["provider.use", "permission"]
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user