diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 19ec7f3db955..ecf60bf98778 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2200,7 +2200,11 @@ export type ConfigEntry = experimental?: { portable_shell_scanner?: boolean subagent_depth?: number - policies?: Array<{ action: "provider.use" | "permission"; resource: string; effect: "allow" | "deny" }> + policies?: Array<{ + action: "provider.use" | "tool.use" | "integration.use" + resource: string + effect: "allow" | "deny" + }> } } } diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts index 30190822f4f6..bd3106c96635 100644 --- a/packages/core/src/config/plugin/policy.ts +++ b/packages/core/src/config/plugin/policy.ts @@ -1,10 +1,10 @@ export * as ConfigPolicyPlugin from "./policy.js" import { define } from "@opencode/plugin/effect/plugin" -import { Document } from "@opencode/schema/config" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { Config } from "../../config.js" import { ManagedPolicy } from "../../managed-policy.js" +import { State } from "../../state.js" import { Wildcard } from "../../util/wildcard.js" import { ConfigEntryObserver } from "./entry-observer.js" @@ -13,25 +13,15 @@ export const Plugin = define({ 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 reload = State.batch(Effect.all([ctx.provider.reload(), ctx.mcp.reload()], { discard: true })) + const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, reload) + yield* managed.changes().pipe( + Stream.runForEach(() => reload), + Effect.forkScoped({ startImmediately: true }), + ) // 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", - })), - ] - } + const policies = () => ManagedPolicy.statements(loaded.entries, managed.current()) yield* ctx.provider.transform((providers) => { const current = policies() for (const record of providers.list()) { @@ -41,6 +31,12 @@ export const Plugin = define({ if (policy?.effect === "deny") providers.remove(record.provider.id) } }) + yield* ctx.mcp.transform((servers) => { + const current = policies() + for (const [name] of servers.list()) { + if (ManagedPolicy.decision(current, "integration.use", `mcp:${name}`) === "deny") servers.remove(name) + } + }) yield* ctx.permission.hook("evaluate", (event) => Effect.sync(() => { const current = policies() @@ -48,7 +44,7 @@ export const Plugin = define({ .map((resource) => current.findLast( (policy) => - policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource), + policy.action === "tool.use" && Wildcard.match(`${event.action}:${resource}`, policy.resource), ), ) .find((policy) => policy?.effect === "deny") diff --git a/packages/core/src/managed-policy.ts b/packages/core/src/managed-policy.ts index facce5560d32..0813a4995f33 100644 --- a/packages/core/src/managed-policy.ts +++ b/packages/core/src/managed-policy.ts @@ -1,7 +1,10 @@ export * as ManagedPolicy from "./managed-policy.js" +import { Document, type Entry } from "@opencode/schema/config" +import { isDeepStrictEqual } from "node:util" +import { Wildcard } from "./util/wildcard.js" import { ConfigPolicy } from "@opencode/schema/config/policy" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, PubSub, Stream } from "effect" import { makeGlobalNode } from "@opencode/util/effect/app-node" /** Policy statements the connected OpenCode Console compiled for whoever it authenticated. */ @@ -12,6 +15,7 @@ export interface State { } export interface Interface { + readonly changes: () => Stream.Stream /** Synchronous so catalog transforms can consult the statements while they run. */ readonly current: () => State /** Replaces the whole state; statements never merge across connections. */ @@ -20,15 +24,52 @@ export interface Interface { 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 layer = Layer.effect( + Service, + Effect.gen(function* () { + const changes = yield* PubSub.unbounded() + const state: { current: State } = { current: { statements: [] } } + return Service.of({ + changes: () => Stream.fromPubSub(changes), + current: () => state.current, + set: (next) => + Effect.gen(function* () { + const changed = + state.current.organization !== next.organization || + !isDeepStrictEqual(state.current.statements, next.statements) + state.current = next + if (!changed) return + yield* PubSub.publish(changes, undefined) + }), + }) + }), +) export const node = makeGlobalNode({ service: Service, layer, deps: [] }) + +export function statements(entries: readonly Entry[], organization: State) { + return [ + ...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", + })), + ] +} + +export function decision( + policies: readonly ConfigPolicy.Info[], + action: ConfigPolicy.Info["action"], + resource: string, +) { + return ( + policies.findLast((policy) => policy.action === action && Wildcard.match(resource, policy.resource))?.effect ?? + "allow" + ) +} diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index 31cab39795f0..6eb061d088df 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -11,9 +11,13 @@ import { fileURLToPath, pathToFileURL } from "url" import type { ConfigPluginSource } from "../config/plugin/source.js" import type { Generation } from "../plugin.js" import { PluginPromise } from "./promise.js" +import { Config } from "../config.js" +import { ManagedPolicy } from "../managed-policy.js" import { Watcher } from "../filesystem/watcher.js" export const make = Effect.fn("PluginModule.make")(function* () { + const config = yield* Config.Service + const managed = yield* ManagedPolicy.Service const watcher = yield* Watcher.Service const scope = yield* Effect.scope const runPromise = yield* FiberSet.makeRuntimePromise() @@ -40,7 +44,21 @@ export const make = Effect.fn("PluginModule.make")(function* () { load: ( operation: Extract, options?: { readonly install?: boolean }, - ) => load(operation, sources, options), + ) => + Effect.gen(function* () { + const version = operation.target.lastIndexOf("@") + const target = + path.isAbsolute(operation.target) || version <= 0 ? operation.target : operation.target.slice(0, version) + if ( + ManagedPolicy.decision( + ManagedPolicy.statements(yield* config.entries(), managed.current()), + "integration.use", + `plugin:${target}`, + ) === "deny" + ) + return { blocked: true as const } + return yield* load(operation, sources, options) + }), changes: () => Stream.fromPubSub(changes), } }) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 1ec0908870c0..85b3d15efcfa 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,4 +1,4 @@ -import { Duration, Effect, Equal, Option, Schema, Scope, Semaphore, Stream } from "effect" +import { Duration, Effect, Equal, Option, Schema, SchemaGetter, Scope, Semaphore, Stream } from "effect" import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" import { define } from "@opencode/plugin/effect/plugin" import type { SessionHttpResponse } from "@opencode/plugin/effect/session" @@ -36,7 +36,20 @@ const RemoteResponse = Schema.Struct({ }).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), + policies: Schema.Array(Schema.Unknown).pipe( + Schema.decodeTo(Schema.Unknown, { + // Filter only unsupported actions. Malformed supported statements still fail validation. + decode: SchemaGetter.transform((policies) => + policies.filter((policy) => { + const action = Schema.decodeUnknownOption(Schema.Struct({ action: Schema.String }))(policy) + return Option.isNone(action) || Schema.is(ConfigPolicy.Info.fields.action)(action.value.action) + }), + ), + encode: SchemaGetter.passthrough({ strict: false }), + }), + Schema.decodeTo(Schema.Array(ConfigPolicy.Info)), + Schema.optional, + ), }).pipe(Schema.optional), }) const Device = Schema.Struct({ @@ -462,11 +475,7 @@ export const OpencodePlugin = define PluginInternal.guarded.has(plugin.id))) const operations = yield* sources.operations() // Activate everything available locally before waiting on missing package installs. const immediate = yield* resolve(modules, pre, post, operations, false, running) @@ -209,6 +220,7 @@ export const layer = Layer.effectDiscard( ) yield* watch(sources.changes()) yield* watch(modules.changes()) + yield* watch(managed.changes()) yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours"))) yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated])) yield* watch( diff --git a/packages/core/test/config/normalization.test.ts b/packages/core/test/config/normalization.test.ts index 5cba5bf7fff8..38b45e594542 100644 --- a/packages/core/test/config/normalization.test.ts +++ b/packages/core/test/config/normalization.test.ts @@ -49,6 +49,35 @@ function withoutEmptyCompatibilityContainers(input: Record) { } describe("ConfigNormalize", () => { + test("accepts tool.use without losing statement order", () => { + const policies = [ + { action: "tool.use", effect: "deny", resource: "shell:*" }, + { action: "tool.use", effect: "allow", resource: "shell:git *" }, + { action: "tool.use", effect: "deny", resource: "shell:git push *" }, + ] as const + const result = normalized({ experimental: { policies } }) + expect(result.diagnostics).toEqual([]) + expect(Schema.decodeUnknownSync(Info)(result.encoded).experimental?.policies).toEqual(policies) + }) + + test("drops unsupported policy actions without losing supported policies", () => { + const supported = { action: "tool.use", effect: "deny", resource: "shell:*" } as const + const result = normalized({ + experimental: { + policies: [ + { action: "permission", effect: "allow", resource: "*" }, + supported, + { action: "future.use", effect: "deny", resource: "*" }, + ], + }, + }) + expect(Schema.decodeUnknownSync(Info)(result.encoded).experimental?.policies).toEqual([supported]) + expect(result.diagnostics.map((diagnostic) => diagnostic.path)).toEqual([ + ["experimental", "policies", "0"], + ["experimental", "policies", "2"], + ]) + }) + test("rejects every non-object root with one root diagnostic", () => { for (const input of [null, [], "config", true, 1]) { expect(ConfigNormalize.normalize(input)).toEqual({ @@ -543,7 +572,14 @@ describe("ConfigNormalize", () => { }, }) expect(result.encoded.providers).not.toHaveProperty(["gateway", "models", "bedrock", "settings", "reasoningConfig"]) - expect(result.encoded.providers).not.toHaveProperty(["gateway", "models", "both", "settings", "thinking", "blockBinding"]) + expect(result.encoded.providers).not.toHaveProperty([ + "gateway", + "models", + "both", + "settings", + "thinking", + "blockBinding", + ]) expect(result.encoded.providers).not.toHaveProperty([ "gateway", "models", diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index 840a2f97e4ba..eb54675408f9 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -25,8 +25,8 @@ const provider = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.I resource, effect, }) -const permission = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({ - action: "permission", +const tool = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({ + action: "tool.use", resource, effect, }) @@ -49,6 +49,29 @@ const evaluate = Effect.fn(function* (action: string, resources: string[], effec }) describe("ConfigPolicyPlugin.Plugin", () => { + it.effect("preserves last-match precedence for tool.use without granting approvals", () => + Effect.gen(function* () { + yield* addPlugin([ + document(tool("deny", "shell:*"), tool("allow", "shell:git *"), tool("deny", "shell:git push *")), + ]) + expect((yield* evaluate("shell", ["git status"], "ask")).effect).toBe("ask") + expect((yield* evaluate("shell", ["git push origin main"])).effect).toBe("deny") + expect((yield* evaluate("shell", ["ls"])).effect).toBe("deny") + expect((yield* evaluate("edit", ["notes.md"])).effect).toBe("allow") + }), + ) + + it.effect("organization tool.use policy overrides authored policy", () => + Effect.gen(function* () { + const managed = yield* ManagedPolicy.Service + yield* managed.set({ statements: [tool("deny", "shell:*")], organization: "Acme" }) + yield* addPlugin([document(tool("allow", "shell:*"))]) + expect(yield* evaluate("shell", ["ls"])).toEqual({ effect: "deny", message: "Blocked by Acme's policy" }) + yield* managed.set({ statements: [tool("allow", "shell:*")] }) + expect((yield* evaluate("shell", ["ls"], "ask")).effect).toBe("ask") + }), + ) + it.effect("filters plugin-provided providers with ordered wildcard policies", () => Effect.gen(function* () { const catalog = yield* Provider.Service @@ -96,7 +119,7 @@ describe("ConfigPolicyPlugin.Plugin", () => { it.effect("denies permissions matched as action:resource", () => Effect.gen(function* () { - yield* addPlugin([document(permission("deny", "shell:git push *"))]) + yield* addPlugin([document(tool("deny", "shell:git push *"))]) expect(yield* evaluate("shell", ["git push"])).toEqual({ effect: "deny", @@ -115,7 +138,7 @@ describe("ConfigPolicyPlugin.Plugin", () => { it.effect("turns an ask into a deny but never grants", () => Effect.gen(function* () { - yield* addPlugin([document(permission("deny", "webfetch:*"), permission("allow", "shell:*"))]) + yield* addPlugin([document(tool("deny", "webfetch:*"), tool("allow", "shell:*"))]) expect((yield* evaluate("webfetch", ["https://example.com"], "ask")).effect).toBe("deny") expect((yield* evaluate("shell", ["ls"], "ask")).effect).toBe("ask") @@ -124,7 +147,7 @@ describe("ConfigPolicyPlugin.Plugin", () => { 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 *"))]) + yield* addPlugin([document(tool("deny", "shell:*"), tool("allow", "shell:git status *"))]) expect((yield* evaluate("shell", ["git status --short"])).effect).toBe("allow") expect((yield* evaluate("shell", ["rm -rf /"])).effect).toBe("deny") @@ -133,7 +156,7 @@ describe("ConfigPolicyPlugin.Plugin", () => { it.effect("denies every permission with a bare wildcard", () => Effect.gen(function* () { - yield* addPlugin([document(permission("deny", "*"))]) + yield* addPlugin([document(tool("deny", "*"))]) expect((yield* evaluate("question", ["*"])).effect).toBe("deny") expect((yield* evaluate("read", ["/tmp/notes.txt"])).effect).toBe("deny") @@ -174,13 +197,13 @@ describe("ConfigPolicyPlugin.Plugin", () => { 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:*"))]) + yield* managed.set({ statements: [tool("deny", "shell:sudo *")], organization: "Acme" }) + yield* addPlugin([document(tool("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 *")] }) + yield* managed.set({ statements: [tool("deny", "shell:sudo *")] }) expect(yield* evaluate("shell", ["sudo ls"])).toEqual({ effect: "deny", message: "Blocked by your organization's policy", diff --git a/packages/core/test/config/snapshot.test.ts b/packages/core/test/config/snapshot.test.ts index 33423b45a624..46700a4bf010 100644 --- a/packages/core/test/config/snapshot.test.ts +++ b/packages/core/test/config/snapshot.test.ts @@ -37,6 +37,7 @@ describe("ConfigSnapshotPlugin.Plugin", () => { const bus = yield* Bus.Service const config = yield* Config.Test const plugins = yield* Plugin.Service + yield* config.setEntries([new Document({ type: "document", info: new Info({ snapshots: false }) })]) yield* ConfigSnapshotPlugin.Plugin.effect(yield* PluginHost.make(plugins)) expect(yield* snapshot.capture()).toBeUndefined() @@ -58,9 +59,6 @@ describe("ConfigSnapshotPlugin.Plugin", () => { ) }), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.provide(PluginTestLayer), - Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ snapshots: false }) })])), - ), + ).pipe(Effect.provide(PluginTestLayer)), ) }) diff --git a/packages/core/test/config/tool-output.test.ts b/packages/core/test/config/tool-output.test.ts index a86db85680e7..d8310cd9a255 100644 --- a/packages/core/test/config/tool-output.test.ts +++ b/packages/core/test/config/tool-output.test.ts @@ -24,6 +24,12 @@ describe("ConfigToolOutputPlugin.Plugin", () => { const bus = yield* Bus.Service const config = yield* Config.Test const plugins = yield* Plugin.Service + yield* config.setEntries([ + new Document({ + type: "document", + info: new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 1 }) }), + }), + ]) yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins)) expect((yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })).metadata?.truncated).toBe( @@ -51,16 +57,6 @@ describe("ConfigToolOutputPlugin.Plugin", () => { ), ), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.provide(PluginTestLayer), - Effect.provide( - Config.testLayer([ - new Document({ - type: "document", - info: new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 1 }) }), - }), - ]), - ), - ), + ).pipe(Effect.provide(PluginTestLayer)), ) }) diff --git a/packages/core/test/fixture/policy.ts b/packages/core/test/fixture/policy.ts new file mode 100644 index 000000000000..bde7dc2fdcf0 --- /dev/null +++ b/packages/core/test/fixture/policy.ts @@ -0,0 +1,40 @@ +import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy" +import { Mcp } from "@opencode/core/mcp/index" +import { ID } from "@opencode/schema/event" +import { Effect, Stream } from "effect" +import { host } from "../plugin/host" + +// Exercise the real policy plugin against a live MCP catalog without unrelated provider/permission setup. +export const registerIntegrationPolicy = Effect.fn(function* ( + mcp: Mcp.Interface, + events: Stream.Stream<{ readonly type: string }, unknown> = Stream.never, +) { + yield* ConfigPolicyPlugin.Plugin.effect( + host({ + event: { + subscribe: () => + events.pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.map(() => ({ id: ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })), + ), + }, + provider: { + list: () => Effect.die("unused provider.list"), + get: () => Effect.die("unused provider.get"), + transform: () => Effect.succeed({ dispose: Effect.void }), + reload: () => Effect.void, + }, + mcp: { + list: () => Effect.die("unused mcp.list"), + transform: (callback) => mcp.transform(callback), + reload: mcp.reload, + }, + permission: { + hook: () => Effect.succeed({ dispose: Effect.void }), + list: () => Effect.die("unused permission.list"), + get: () => Effect.die("unused permission.get"), + reply: () => Effect.die("unused permission.reply"), + }, + }), + ) +}) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index cbaa724c38f7..1819afe4cda0 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -12,6 +12,8 @@ import { import { Document, Event, Info } from "@opencode/schema/config" import { ConfigMCP } from "@opencode/schema/config/mcp" import { McpEvent } from "@opencode/schema/mcp-event" +import { ConfigPolicy } from "@opencode/schema/config/policy" +import { ManagedPolicy } from "@opencode/core/managed-policy" import { Config } from "@opencode/core/config" import { ConfigMcpPlugin } from "@opencode/core/config/plugin/mcp" import { Credential } from "@opencode/core/credential" @@ -56,6 +58,7 @@ import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildPr import { Image } from "@opencode/core/image" import { advance, drain } from "./lib/clock" import { testEffect } from "./lib/effect" +import { registerIntegrationPolicy } from "./fixture/policy" import { imagePassthrough } from "./lib/image" import { location } from "./fixture/location" import { tmpdirScoped } from "./fixture/tmpdir" @@ -269,6 +272,8 @@ function resourceMcpLayer( onFormCreated?: (form: Form.Info) => Effect.Effect, options?: Mcp.Options, overrides?: { + managed?: ManagedPolicy.Interface + policies?: readonly ConfigPolicy.Info[] entries?: Config.Interface["entries"] subscribe?: Bus.Interface["subscribe"] environment?: Layer.Layer @@ -280,13 +285,20 @@ function resourceMcpLayer( return Layer.effectDiscard( Effect.gen(function* () { const bus = yield* Bus.Service - yield* ConfigMcpPlugin.register(bus.subscribe()) + const mcp = yield* Mcp.Service + yield* State.batch( + Effect.gen(function* () { + yield* ConfigMcpPlugin.register(bus.subscribe()) + yield* registerIntegrationPolicy(mcp, bus.subscribe()) + }), + ) }), ).pipe( Layer.provideMerge(Mcp.layer(options)), Layer.provideMerge(Form.layer), Layer.provide( Layer.mergeAll( + overrides?.managed ? Layer.succeed(ManagedPolicy.Service, overrides.managed) : ManagedPolicy.layer, overrides?.entries ? Layer.succeed( Config.Service, @@ -299,6 +311,7 @@ function resourceMcpLayer( new Document({ type: "document", info: new Info({ + experimental: { policies: overrides?.policies ?? [] }, mcp: new ConfigMCP.Info({ servers: { resources: @@ -1990,7 +2003,16 @@ testEffect(Layer.empty).live("keeps MCP config snapshots stable during an in-fli const shutdownIt = testEffect( AppNodeBuilder.build( - LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]), + LayerNode.group([ + Config.node, + ManagedPolicy.node, + Bus.node, + Integration.node, + Credential.node, + Form.node, + Environment.node, + Location.node, + ]), [ Location.node.replace( Layer.succeed( @@ -2559,3 +2581,123 @@ it.effect("does not call MCP when permission is blocked", () => expect(calls).toBe(0) }), ) + +for (const modern of [false, true]) { + testEffect(Layer.empty).live(`integration policy denies MCP startup (${modern ? "modern" : "legacy"})`, () => + Effect.gen(function* () { + const server = yield* resourceServer({ modern }) + yield* Effect.gen(function* () { + const service = yield* Mcp.Service + expect(yield* service.servers()).toEqual([]) + expect(Exit.isFailure(yield* service.connect("resources").pipe(Effect.exit))).toBe(true) + yield* service.add("resources", { type: "remote", url: server.url, oauth: false }) + expect(yield* service.servers()).toEqual([]) + expect(yield* service.tools()).toEqual([]) + expect(yield* service.instructions()).toEqual([]) + expect(yield* service.prompts()).toEqual([]) + expect(yield* service.resourceCatalog()).toEqual({ resources: [], templates: [] }) + expect(Exit.isFailure(yield* service.resources({ server: "resources" }).pipe(Effect.exit))).toBe(true) + expect(Exit.isFailure(yield* service.prompt({ server: "resources", name: "greet" }).pipe(Effect.exit))).toBe( + true, + ) + expect( + Exit.isFailure(yield* service.readResource({ server: "resources", uri: "docs://readme" }).pipe(Effect.exit)), + ).toBe(true) + expect(Exit.isFailure(yield* service.callTool({ server: "resources", name: "echo" }).pipe(Effect.exit))).toBe( + true, + ) + expect(server.state.initializations).toBe(0) + }).pipe( + Effect.provide( + resourceMcpLayer(server.url, undefined, undefined, { + policies: [{ action: "integration.use", resource: "mcp:*", effect: "deny" }], + }), + ), + ) + }), + ) +} + +for (const source of ["config", "organization"] as const) { + testEffect(Layer.mergeAll(Config.testLayer(), ManagedPolicy.layer)).live( + `revokes and restores a connected MCP through ${source} policy`, + () => + Effect.gen(function* () { + const server = yield* resourceServer() + const managed = yield* ManagedPolicy.Service + const config = yield* Config.Service + const test = yield* Config.Test + const updates = yield* PubSub.unbounded<{ readonly type: string }>() + const document = (effect: ConfigPolicy.Effect) => + new Document({ + type: "document", + info: new Info({ + mcp: new ConfigMCP.Info({ + servers: { resources: new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }) }, + }), + experimental: { policies: [{ action: "integration.use", resource: "mcp:*", effect }] }, + }), + }) + yield* test.setEntries([document("allow")]) + yield* Effect.gen(function* () { + const service = yield* Mcp.Service + expect(yield* settled(service)).toEqual({ status: "connected" }) + expect((yield* service.tools()).length).toBeGreaterThan(0) + if (source === "config") yield* test.setEntries([document("deny")]) + if (source === "organization") + yield* managed.set({ statements: [{ action: "integration.use", resource: "mcp:*", effect: "deny" }] }) + if (source === "config") yield* PubSub.publish(updates, { type: Event.Updated.type }) + yield* service.servers().pipe( + Effect.filterOrFail( + (servers) => servers.length === 0, + () => new Error("MCP policy was not applied"), + ), + Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), + ) + expect(yield* service.servers()).toEqual([]) + expect(yield* service.tools()).toEqual([]) + expect(yield* service.instructions()).toEqual([]) + expect(yield* service.prompts()).toEqual([]) + expect(yield* service.resourceCatalog()).toEqual({ resources: [], templates: [] }) + expect(Exit.isFailure(yield* service.resources({ server: "resources" }).pipe(Effect.exit))).toBe(true) + expect(Exit.isFailure(yield* service.prompt({ server: "resources", name: "greet" }).pipe(Effect.exit))).toBe( + true, + ) + expect( + Exit.isFailure( + yield* service.readResource({ server: "resources", uri: "docs://readme" }).pipe(Effect.exit), + ), + ).toBe(true) + expect(Exit.isFailure(yield* service.connect("resources").pipe(Effect.exit))).toBe(true) + expect(Exit.isFailure(yield* service.callTool({ server: "resources", name: "echo" }).pipe(Effect.exit))).toBe( + true, + ) + expect(server.state.toolCalls).toEqual([]) + expect(server.state.resourceReads).toEqual([]) + yield* Effect.promise(server.restart) + if (source === "config") { + yield* test.setEntries([document("allow")]) + yield* PubSub.publish(updates, { type: Event.Updated.type }) + } + if (source === "organization") yield* managed.set({ statements: [] }) + yield* service.servers().pipe( + Effect.filterOrFail( + (servers) => servers.some((server) => server.status.status === "connected"), + () => new Error("MCP policy was not restored"), + ), + Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), + ) + expect(yield* settled(service)).toEqual({ status: "connected" }) + expect((yield* service.tools()).length).toBeGreaterThan(0) + }).pipe( + Effect.provide( + resourceMcpLayer(server.url, undefined, undefined, { + entries: config.entries, + managed, + subscribe: (() => Stream.fromPubSub(updates)) as Bus.Interface["subscribe"], + }), + ), + ) + }), + ) +} diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 70746c04708a..74fd92ead9a2 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -98,7 +98,7 @@ it.live("loads a local plugin with its configured options", () => target: path.join(import.meta.dir, "plugin/fixtures/greeting.ts"), options: { description: "Configured greeting" }, }) - if ("pending" in definition) return yield* Effect.die("Local plugin was not loaded") + if ("pending" in definition || "blocked" in definition) return yield* Effect.die("Local plugin was not loaded") yield* plugins.activate([definition]) expect(yield* commands.get("greet")).toMatchObject({ description: "Configured greeting" }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 0ac476e36485..0cc8e9e99b83 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -53,6 +53,8 @@ const npmLayer = Layer.succeed( const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text: () => Effect.succeed("") })) +const configLayer = Config.testLayer() + const permissionLayer = Layer.succeed( Permission.Service, Permission.Service.of({ @@ -74,6 +76,7 @@ export const PluginTestLayer = AppNodeBuilder.build( Location.node, Npm.node, Credential.node, + Config.node, Bus.node, Form.node, Generate.node, @@ -107,9 +110,9 @@ export const PluginTestLayer = AppNodeBuilder.build( [ Location.node.replace(tempLocationLayer), Npm.node.replace(npmLayer), - Config.node.replace(Config.testLayer()), + Config.node.replace(configLayer), Mcp.node.replace(emptyMcpLayer), Generate.node.replace(generateLayer), Permission.node.replace(permissionLayer), ], -) +).pipe(Layer.provideMerge(configLayer)) diff --git a/packages/core/test/plugin/module.test.ts b/packages/core/test/plugin/module.test.ts index d93716f4e0d7..1b4c2087c686 100644 --- a/packages/core/test/plugin/module.test.ts +++ b/packages/core/test/plugin/module.test.ts @@ -3,6 +3,8 @@ import path from "node:path" import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Watcher } from "@opencode/core/filesystem/watcher" +import { Config } from "@opencode/core/config" +import { ManagedPolicy } from "@opencode/core/managed-policy" import { PluginModule } from "@opencode/core/plugin/module" import { Global } from "@opencode/util/global" import { Npm } from "@opencode/util/npm" @@ -11,7 +13,9 @@ import { tmpdirScoped } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.merge( + Layer.mergeAll( + Config.testLayer(), + ManagedPolicy.layer, AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]), Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)), ), @@ -90,3 +94,47 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo yield* Effect.promise(() => Bun.sleep(50)) }), ) + +it.live("blocks a local plugin before module initialization and honors replacement policies", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const entered = path.join(directory.path, "entered") + yield* Effect.promise(() => + Bun.write( + path.join(directory.path, "index.ts"), + ` + await Bun.write(${JSON.stringify(entered)}, "loaded") + export default { id: "policy-fixture", async setup() {} } + `, + ), + ) + const managed = yield* ManagedPolicy.Service + const modules = yield* PluginModule.make() + const operation = { type: "add" as const, target: directory.path, options: {} } + yield* managed.set({ + statements: [{ action: "integration.use", resource: `plugin:${directory.path}`, effect: "deny" }], + }) + expect(yield* modules.load(operation)).toEqual({ blocked: true }) + expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(false) + yield* managed.set({ statements: [] }) + expect(yield* modules.load(operation)).toMatchObject({ id: "policy-fixture" }) + expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(true) + }), +) + +for (const target of ["policy-fixture@1.2.3", "@scope/policy-fixture@1.2.3"]) { + it.live(`blocks versioned package ${target} before npm resolution or installation`, () => + Effect.gen(function* () { + const managed = yield* ManagedPolicy.Service + const modules = yield* PluginModule.make() + yield* managed.set({ + statements: [ + { action: "integration.use", resource: `plugin:${target.slice(0, target.lastIndexOf("@"))}`, effect: "deny" }, + ], + }) + const operation = { type: "add" as const, target, options: {} } + expect(yield* modules.load(operation, { install: false })).toEqual({ blocked: true }) + expect(yield* modules.load(operation, { install: true })).toEqual({ blocked: true }) + }), + ) +} diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index edb391975492..dba781f0a208 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -751,7 +751,7 @@ describe("OpencodePlugin", () => { ), ) - it.effect("enforces organization policy statements from the Console", () => + it.effect("ignores unsupported managed actions and keys while enforcing supported policies", () => Effect.acquireUseRelease( Effect.sync(() => Bun.serve({ @@ -761,12 +761,18 @@ describe("OpencodePlugin", () => { providers: { opencode: {} }, experimental: { policies: [ + { action: "future.use", resource: { names: ["anything"] }, effect: "future-effect", future: true }, { action: "provider.use", resource: "*", effect: "deny" }, { action: "provider.use", resource: "opencode", effect: "allow" }, - { action: "permission", resource: "shell:sudo *", effect: "deny", audience: "ignored" }, + { action: "tool.use", resource: "shell:*", effect: "deny" }, + { action: "tool.use", resource: "shell:git *", effect: "allow" }, + { action: "permission", resource: "*", effect: "deny" }, + { action: "tool.use", resource: "shell:sudo *", effect: "deny", audience: "ignored" }, + { action: "integration.use", resource: "mcp:restricted", effect: "deny" }, ], - unknown: true, + unknown: { future: true }, }, + future: { unknown: true }, }), }), ), @@ -793,10 +799,30 @@ describe("OpencodePlugin", () => { statements: [ { action: "provider.use", resource: "*", effect: "deny" }, { action: "provider.use", resource: "opencode", effect: "allow" }, - { action: "permission", resource: "shell:sudo *", effect: "deny" }, + { action: "tool.use", resource: "shell:*", effect: "deny" }, + { action: "tool.use", resource: "shell:git *", effect: "allow" }, + { action: "tool.use", resource: "shell:sudo *", effect: "deny" }, + { action: "integration.use", resource: "mcp:restricted", effect: "deny" }, ], organization: "Acme", }) + const hooks = yield* PluginHooks.Service + const decision = yield* hooks.trigger("permission", "evaluate", { + sessionID: Session.ID.make("ses_tool_policy"), + action: "shell", + resources: ["sudo ls"], + effect: "allow", + }) + expect(decision.effect).toBe("deny") + expect(decision.message).toBe("Blocked by Acme's policy") + expect( + (yield* hooks.trigger("permission", "evaluate", { + sessionID: Session.ID.make("ses_tool_policy"), + action: "shell", + resources: ["git status"], + effect: "ask", + })).effect, + ).toBe("ask") expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined() expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined() }), @@ -804,6 +830,66 @@ describe("OpencodePlugin", () => { ), ) + it.effect("refreshes past future-only policies while retaining config on malformed supported statements", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const state: { policies: unknown[] } = { + policies: [{ action: "tool.use", resource: "shell:sudo *", effect: "deny" }], + } + const server = Bun.serve({ + port: 0, + fetch: () => Response.json({ providers: { opencode: {} }, experimental: { policies: state.policies } }), + }) + return { server, state } + }), + ({ server, state }) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const managed = yield* ManagedPolicy.Service + const providers = yield* Provider.Service + const sudo: ConfigPolicy.Info = { action: "tool.use", resource: "shell:sudo *", effect: "deny" } + const env: ConfigPolicy.Info = { action: "tool.use", resource: "edit:*.env", effect: "deny" } + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: Credential.Key.make({ + type: "key", + key: "secret", + metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" }, + }), + }) + yield* addPlugin() + yield* drain + expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" }) + + for (const malformed of [ + { action: "tool.use", resource: "shell:*", effect: "future-effect" }, + { action: "tool.use", resource: 42, effect: "deny" }, + { action: "tool.use", effect: "deny" }, + null, + {}, + ]) { + state.policies = [env, malformed] + yield* TestClock.adjust("1 minute") + yield* drain + expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" }) + expect(yield* providers.get(Provider.ID.opencode)).toBeDefined() + } + + state.policies = [{ action: "future.use", future: { values: [true] } }] + yield* TestClock.adjust("1 minute") + yield* drain + expect(managed.current()).toEqual({ statements: [], organization: "Acme" }) + expect(yield* providers.get(Provider.ID.opencode)).toBeDefined() + + state.policies = [{ action: "future.use", effect: { next: true } }, env] + yield* TestClock.adjust("1 minute") + yield* drain + expect(managed.current()).toEqual({ statements: [env], organization: "Acme" }) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + it.effect("keeps policy statements bound to the connected Console account", () => Effect.acquireUseRelease( Effect.sync(() => { @@ -838,8 +924,8 @@ describe("OpencodePlugin", () => { 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" } + const sudo: ConfigPolicy.Info = { action: "tool.use", resource: "shell:sudo *", effect: "deny" } + const env: ConfigPolicy.Info = { action: "tool.use", resource: "edit:*.env", effect: "deny" } yield* providers.transform(() => { rebuilds.count++ }) @@ -917,7 +1003,7 @@ describe("OpencodePlugin", () => { ) return Response.json({ providers: {}, - experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] }, + experimental: { policies: [{ action: "tool.use", resource: "shell:sudo *", effect: "deny" }] }, }) }, }) @@ -932,7 +1018,7 @@ describe("OpencodePlugin", () => { integrations .get(Integration.ID.make("opencode")) .pipe(Effect.map((integration) => integration?.connections[0]?.status)) - const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" } + const sudo: ConfigPolicy.Info = { action: "tool.use", resource: "shell:sudo *", effect: "deny" } yield* credentials.create({ integrationID: Integration.ID.make("opencode"), value: Credential.Key.make({ @@ -1039,7 +1125,7 @@ describe("OpencodePlugin", () => { state.body ?? Response.json({ providers: {}, - experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] }, + experimental: { policies: [{ action: "tool.use", resource: "shell:sudo *", effect: "deny" }] }, }), }) return { server, state } @@ -1049,7 +1135,7 @@ describe("OpencodePlugin", () => { const credentials = yield* Credential.Service const integrations = yield* Integration.Service const managed = yield* ManagedPolicy.Service - const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" } + const sudo: ConfigPolicy.Info = { action: "tool.use", resource: "shell:sudo *", effect: "deny" } yield* credentials.create({ integrationID: Integration.ID.make("opencode"), value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }), diff --git a/packages/core/test/plugin/supervisor-reload.test.ts b/packages/core/test/plugin/supervisor-reload.test.ts index c4005882e691..c136ce17ef28 100644 --- a/packages/core/test/plugin/supervisor-reload.test.ts +++ b/packages/core/test/plugin/supervisor-reload.test.ts @@ -13,6 +13,7 @@ import { Bus } from "@opencode/core/bus" import { Command } from "@opencode/core/command" import { Database } from "@opencode/core/database/database" import { Watcher } from "@opencode/core/filesystem/watcher" +import { ManagedPolicy } from "@opencode/core/managed-policy" import { Instance } from "@opencode/core/instance" import { LocationServiceMap } from "@opencode/core/location-services" import { Location } from "@opencode/core/location" @@ -59,11 +60,13 @@ const instances = Layer.effect( LocationServiceMap.Service, Effect.gen(function* () { const watcher = yield* Watcher.Test + const managed = yield* ManagedPolicy.Service const map = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), { idleTimeToLive: Duration.infinity, }) const bindings: LayerNode.Replacements = [ Global.node.replace(tempGlobalLayer), + ManagedPolicy.node.replace(Layer.succeed(ManagedPolicy.Service, managed)), offlineModels, Npm.node.replace(npmLayer), Watcher.node.replace(Layer.succeed(Watcher.Service, watcher)), @@ -76,14 +79,13 @@ const instances = Layer.effect( ] return map }), -).pipe(Layer.provide(Watcher.testLayer)) +).pipe(Layer.provide(Watcher.testLayer), Layer.provide(ManagedPolicy.layer)) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ - Global.node.replace(tempGlobalLayer), - offlineModels, - LocationServiceMap.node.replace(instances), - ]).pipe(Layer.provideMerge(Watcher.testLayer)), + AppNodeBuilder.build( + LayerNode.group([ManagedPolicy.node, Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), + [Global.node.replace(tempGlobalLayer), offlineModels, LocationServiceMap.node.replace(instances)], + ).pipe(Layer.provideMerge(Watcher.testLayer)), ) const greeter = (command: string) => `export default { @@ -337,3 +339,57 @@ describe("PluginSupervisor reload", () => { }), ) }) + +it.live("applies organization integration changes to running external plugins", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const root = path.join(directory.path, "external/policy-fixture") + const entered = path.join(directory.path, "entered") + const configuration = path.join(directory.path, ".opencode/opencode.json") + yield* Effect.promise(async () => { + await Bun.write( + path.join(root, "index.ts"), + `await Bun.write(${JSON.stringify(entered)}, "loaded"); ${greeter("policy-greet")}`, + ) + await Bun.write( + configuration, + JSON.stringify({ + plugins: [root], + experimental: { policies: [{ action: "integration.use", resource: "plugin:*", effect: "deny" }] }, + }), + ) + }) + const locations = yield* LocationServiceMap.Service + yield* Effect.gen(function* () { + const plugins = yield* Plugin.Service + const commands = yield* Command.Service + const managed = yield* ManagedPolicy.Service + const bus = yield* Bus.Service + yield* plugins.awaitActivation + expect(yield* commands.get("policy-greet")).toBeUndefined() + expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(false) + yield* Effect.promise(() => Bun.write(configuration, JSON.stringify({ plugins: [root] }))) + yield* bus.publish(Event.Updated, {}) + yield* commands.get("policy-greet").pipe( + Effect.flatMap((command) => (command ? Effect.void : Effect.fail("pending"))), + Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), + ) + expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(true) + yield* managed.set({ statements: [{ action: "integration.use", resource: `plugin:${root}`, effect: "deny" }] }) + yield* commands.get("policy-greet").pipe( + Effect.flatMap((command) => (command ? Effect.fail("pending") : Effect.void)), + Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), + ) + yield* plugins.awaitActivation + expect((yield* plugins.list()).some((plugin) => plugin.id === "greeter")).toBe(false) + yield* managed.set({ statements: [] }) + yield* commands.get("policy-greet").pipe( + Effect.flatMap((command) => (command ? Effect.void : Effect.fail("pending"))), + Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), + ) + }).pipe( + Effect.scoped, + Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))), + ) + }), +) diff --git a/packages/schema/src/config/policy.ts b/packages/schema/src/config/policy.ts index beeaaa613bad..a0a071c3d9f8 100644 --- a/packages/schema/src/config/policy.ts +++ b/packages/schema/src/config/policy.ts @@ -6,7 +6,7 @@ export const Effect = Schema.Literals(["allow", "deny"]) export type Effect = typeof Effect.Type export const Info = Schema.Struct({ - action: Schema.Literals(["provider.use", "permission"]), + action: Schema.Literals(["provider.use", "tool.use", "integration.use"]), resource: Schema.String, effect: Effect, }) diff --git a/packages/schema/test/config.test.ts b/packages/schema/test/config.test.ts index 7f0f8a61fedb..2fa7ab76ee35 100644 --- a/packages/schema/test/config.test.ts +++ b/packages/schema/test/config.test.ts @@ -10,6 +10,25 @@ import { AbsolutePath } from "../src/schema.js" import { WebSearch } from "../src/websearch.js" describe("Config.Entry", () => { + test("round-trips tool.use and rejects unsupported action spellings", () => { + const input = { + experimental: { + policies: [ + { action: "tool.use", resource: "shell:*", effect: "deny" }, + { action: "tool.use", resource: "shell:git *", effect: "allow" }, + ], + }, + } as const + const decoded = Schema.decodeUnknownSync(Config.Info)(input) + expect(Schema.encodeSync(Config.Info)(decoded)).toEqual(input) + for (const action of ["permission", "tool.execute"]) + expect(() => + Schema.decodeUnknownSync(Config.Info)({ + experimental: { policies: [{ action, resource: "*", effect: "deny" }] }, + }), + ).toThrow() + }) + test("accepts directory-only worktree config and omits it when absent", () => { const decode = Schema.decodeUnknownSync(Config.Info) const input = { worktree: { directory: "../worktrees" } } diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md index 0ac5afa1a0a4..5fc722574534 100644 --- a/specs/v2/provider-policy.md +++ b/specs/v2/provider-policy.md @@ -12,7 +12,7 @@ Two consumers exist: action: provider.use resource: provider ID, such as openai or company-ai -action: permission +action: tool.use resource: :, such as shell:sudo * or edit:*.env ``` @@ -37,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. -- A `permission` statement never grants access; permissions and saved approvals still decide `allow` versus `ask`. +- A `tool.use` statement never grants access; permissions and saved approvals still decide `allow` versus `ask`. ## Statement Shape @@ -58,12 +58,12 @@ A provider can be correctly configured and have valid credentials while policy s ```ts interface PolicyInfo { effect: "allow" | "deny" - action: "provider.use" | "permission" + action: "provider.use" | "tool.use" | "integration.use" resource: string } ``` -`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. +`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 `tool.use` after every other permission evaluation hook. ## Matching @@ -75,8 +75,8 @@ Examples: | -------------- | ------------------ | --------------------------------------------------------------- | | `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 | +| `tool.use` | `shell:git push *` | The `shell` permission for `git push` with or without arguments | +| `tool.use` | `*` | 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. @@ -214,7 +214,7 @@ The OpenCode Console compiles a workspace's Providers and Tools policies into st "policies": [ { "action": "provider.use", "resource": "*", "effect": "deny" }, { "action": "provider.use", "resource": "opencode", "effect": "allow" }, - { "action": "permission", "resource": "shell:sudo *", "effect": "deny" }, + { "action": "tool.use", "resource": "shell:sudo *", "effect": "deny" }, ], }, } @@ -233,7 +233,7 @@ A config fetch or credential refresh that fails for the connection already in pl ### 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`. +When the deciding `tool.use` 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 @@ -243,13 +243,17 @@ Plugin `remove` operations in config ignore `opencode.config.policy` and `openco 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 +## Tool 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. +`tool.use` is the only tool-policy action. An allow statement never overrides an agent/session denial or grants a saved approval. + +Console-managed config ignores unknown fields and skips statements whose action is unsupported, preserving the order of every supported statement. A future action cannot invalidate the providers or supported policies in the response. Supported actions still require a valid resource and effect; malformed supported statements remain configuration errors. Skipped actions are not enforced by that client. Authored config drops unsupported statements with a diagnostic. + +`tool.use` 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 +tool.use / shell:sudo ls -> deny (statement shell:sudo *) +tool.use / 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. @@ -296,7 +300,7 @@ Flow: 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. A changed Console snapshot rebuilds the catalog through the Console plugin's own reload, which re-runs the terminal transform. +Config reload refreshes the plugin's policy snapshot and rebuilds the catalog. A changed Console snapshot notifies the policy plugin to rebuild its catalogs and re-run the terminal transforms. ## Legacy Migration @@ -342,3 +346,11 @@ Equivalent v2 policy: }, } ``` + +## MCP Integration Policy + +The protected terminal config policy plugin applies `integration.use` statements to `mcp:` using `ctx.mcp.transform`. It removes denied servers after configured and plugin-provided catalog entries are assembled. The MCP domain owns connections and resource cleanup; it does not interpret policy statements. + +Plugin activation batches catalog registrations, so denied servers never open an initial connection. Config and managed policy updates reload the catalog. Removing an existing server closes its connection and removes its tools, instructions, prompts, resources, and resource templates. Explicit connect and direct tool, prompt, and resource calls reject removed servers through the ordinary not-found path. Allowing the server again restores its catalog entry and reconnects it. + +Policies are evaluated after authored statements and then organization statements, with the final matching statement deciding access. API-added server overrides are inputs to the same catalog, so they also pass through the policy transform. Catalog removal does not sandbox arbitrary executable plugins; plugin import governance remains a separate `plugin:*` integration policy check.