diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts index 9e179d116e1..30190822f4f 100644 --- a/packages/core/src/config/plugin/policy.ts +++ b/packages/core/src/config/plugin/policy.ts @@ -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 }), ) }), diff --git a/packages/core/src/managed-policy.ts b/packages/core/src/managed-policy.ts new file mode 100644 index 00000000000..facce5560d3 --- /dev/null +++ b/packages/core/src/managed-policy.ts @@ -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 + /** 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 +} + +export class Service extends Context.Service()("@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: [] }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d42a4d1d7e4..e9a6f989a1c 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -50,6 +50,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" @@ -92,6 +93,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" @@ -126,6 +128,7 @@ const services = [ KV.Service, LLMClient.Service, Location.Service, + ManagedPolicy.Service, ModelsDev.Service, Mcp.Service, Npm.Service, @@ -178,6 +181,7 @@ export const requirements = LayerNode.group([ KV.node, llmClient, Location.node, + ManagedPolicy.node, ModelsDev.node, Mcp.node, Npm.node, @@ -260,6 +264,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 = 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()) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index c3beee70820..8fd2772acb0 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -7,8 +7,10 @@ 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" @@ -20,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, @@ -112,38 +118,50 @@ function oauth(http: HttpClient.HttpClient) { } satisfies IntegrationOAuthMethodRegistration } -export const OpencodePlugin = define({ +export const OpencodePlugin = define({ 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> 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( - IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection) - ? snapshot.config - : 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) => { @@ -154,6 +172,7 @@ export const OpencodePlugin = define { for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) { const source = providers.get(item.canonical ?? providerID) @@ -316,6 +335,7 @@ export const OpencodePlugin = define loading.withPermit(load().pipe(Effect.andThen(apply))) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 32de3582721..bb5a8e7e5a9 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -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 } diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index c8e5ff7c59c..840a2f97e4b 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -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() + }), ) }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 451f874ab1f..0ac476e3648 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -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, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 2b8888e7cbf..ba120a3d873 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -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" @@ -628,6 +632,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; 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(() => { diff --git a/packages/core/test/plugin/supervisor.test.ts b/packages/core/test/plugin/supervisor.test.ts index b96c3afed73..111a02b2074 100644 --- a/packages/core/test/plugin/supervisor.test.ts +++ b/packages/core/test/plugin/supervisor.test.ts @@ -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 diff --git a/services/www/src/docs/content/config.mdx b/services/www/src/docs/content/config.mdx index c7bea615c70..adcacb53dcc 100644 --- a/services/www/src/docs/content/config.mdx +++ b/services/www/src/docs/content/config.mdx @@ -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, diff --git a/services/www/src/docs/content/console/index.mdx b/services/www/src/docs/content/console/index.mdx index 3f8afa5a96c..7903e91fb47 100644 --- a/services/www/src/docs/content/console/index.mdx +++ b/services/www/src/docs/content/console/index.mdx @@ -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. diff --git a/services/www/src/docs/content/migrate-v1.mdx b/services/www/src/docs/content/migrate-v1.mdx index a20a9fb717f..760bfc691da 100644 --- a/services/www/src/docs/content/migrate-v1.mdx +++ b/services/www/src/docs/content/migrate-v1.mdx @@ -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. diff --git a/services/www/src/docs/content/permissions.mdx b/services/www/src/docs/content/permissions.mdx index a2b90d8e358..a8e540213c3 100644 --- a/services/www/src/docs/content/permissions.mdx +++ b/services/www/src/docs/content/permissions.mdx @@ -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. diff --git a/services/www/src/docs/content/plugins.mdx b/services/www/src/docs/content/plugins.mdx index 767528ee1dc..4901d3d17c4 100644 --- a/services/www/src/docs/content/plugins.mdx +++ b/services/www/src/docs/content/plugins.mdx @@ -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. diff --git a/services/www/src/docs/content/policies.mdx b/services/www/src/docs/content/policies.mdx new file mode 100644 index 00000000000..ea021b7ccb1 --- /dev/null +++ b/services/www/src/docs/content/policies.mdx @@ -0,0 +1,216 @@ +--- +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": { + "package": "@opencode/ai/providers/openai-compatible", + "settings": { "baseURL": "https://ai.company.example/v1" }, + }, + }, + "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 +`:`, 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). + + + 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. + diff --git a/services/www/src/docs/lib/navigation.ts b/services/www/src/docs/lib/navigation.ts index 2e8f7d337dd..676b047b8f8 100644 --- a/services/www/src/docs/lib/navigation.ts +++ b/services/www/src/docs/lib/navigation.ts @@ -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" }, diff --git a/specs/v2/README.md b/specs/v2/README.md index d3f1a872e58..755aafa3502 100644 --- a/specs/v2/README.md +++ b/specs/v2/README.md @@ -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 diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md index aa14bf35a35..ae3be769ddb 100644 --- a/specs/v2/provider-policy.md +++ b/specs/v2/provider-policy.md @@ -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: :, 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,26 +197,72 @@ 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 '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 `:` 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 { "providers": { "company-ai": { - "endpoint": { - "type": "openai/responses", - "url": "https://ai.company.example/v1/responses", - }, + "package": "@opencode/ai/providers/openai-compatible", + "settings": { "baseURL": "https://ai.company.example/v1" }, }, }, "experimental": { @@ -240,12 +290,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