Compare commits

...
Author SHA1 Message Date
Adam e4b9c365fb feat(opencode): enforce integration policies 2026-10-07 05:17:06 -05:00
10 changed files with 207 additions and 22 deletions

No files matched your search

+5 -1
View File
@@ -6,7 +6,11 @@ import { Policy as PolicyV2 } from "../policy"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
export const PolicyAction = Schema.Union([
Catalog.PolicyActions,
Schema.Literal("integration.use"),
Schema.Literal("permission"),
])
export class Policy extends Schema.Class<Policy>("ConfigV2.Experimental.Policy")({
...PolicyV2.Info.fields,
@@ -8,6 +8,7 @@ import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Policy } from "../../policy"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
@@ -36,6 +37,7 @@ export const Plugin = define({
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const policy = yield* Policy.Service
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
@@ -71,6 +73,12 @@ export const Plugin = define({
}
for (const ref of configured) {
// Check before installing or importing: module initialization can have side effects.
const packageName =
!path.isAbsolute(ref.package) && ref.package.lastIndexOf("@") > 0
? ref.package.slice(0, ref.package.lastIndexOf("@"))
: ref.package
if ((yield* policy.evaluate("integration.use", `plugin:${packageName}`, "allow")) === "deny") continue
yield* Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
+5
View File
@@ -21,6 +21,7 @@ import { Global } from "../global"
import { Integration } from "../integration"
import { Location } from "../location"
import { ModelsDev } from "../models-dev"
import { Policy } from "../policy"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { Reference } from "../reference"
@@ -48,6 +49,7 @@ export type Requirements =
| Location.Service
| ModelsDev.Service
| Npm.Service
| Policy.Service
| Reference.Service
| SkillV2.Service
@@ -71,6 +73,7 @@ const layer = Layer.effectDiscard(
const location = yield* Location.Service
const modelsDev = yield* ModelsDev.Service
const npm = yield* Npm.Service
const policy = yield* Policy.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
@@ -93,6 +96,7 @@ const layer = Layer.effectDiscard(
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(Policy.Service, policy),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
@@ -142,6 +146,7 @@ export const node = makeLocationNode({
Location.node,
ModelsDev.node,
Npm.node,
Policy.node,
EventV2.node,
FSUtil.node,
FileSystem.node,
+10 -5
View File
@@ -34,11 +34,7 @@ const layer = Layer.effect(
}),
hasStatements: () => statements.length > 0,
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
return decision(statements, action, resource, fallback)
}),
})
}),
@@ -47,3 +43,12 @@ const layer = Layer.effect(
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
/** Shared statement ordering for the location runtime and the legacy configuration runtime. */
export function decision(statements: ReadonlyArray<Info>, action: string, resource: string, fallback: Effect): Effect {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}
+47
View File
@@ -9,6 +9,7 @@ import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -17,6 +18,52 @@ const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigExternalPlugin", () => {
it.live("blocks a plugin before importing its module, including local paths containing @", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const location = yield* Location.Service
const policy = yield* Policy.Service
const host = yield* PluginHost.make(plugins)
const blocked = path.join(location.directory, "blocked@plugin.ts")
const marker = path.join(location.directory, "imported.txt")
yield* Effect.promise(() =>
Bun.write(
blocked,
`await Bun.write(${JSON.stringify(marker)}, "loaded"); export default { id: "blocked", setup() {} }`,
),
)
yield* policy.load([
new Policy.Info({ action: "integration.use", resource: `plugin:${blocked}`, effect: "deny" }),
])
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
path: path.join(import.meta.dir, "opencode.json"),
info: decode({
plugins: [
blocked,
{
package: "../plugin/fixtures/config-promise-plugin.ts",
options: { description: "After blocked plugin" },
},
],
}),
}),
]),
}),
),
)
expect(yield* waitForAgent(agents, "configured")).toMatchObject({ description: "After blocked plugin" })
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
)
it.live("resolves and loads a configured Promise plugin with options", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
+2
View File
@@ -1,5 +1,6 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Policy } from "../../src/policy"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Credential } from "@opencode-ai/core/credential"
@@ -40,6 +41,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
AgentV2.node,
AISDK.node,
Catalog.node,
Policy.node,
CommandV2.node,
Integration.node,
Reference.node,
+55 -14
View File
@@ -1,6 +1,7 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Policy } from "@opencode-ai/core/policy"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
@@ -208,6 +209,12 @@ const layer = Layer.effect(
const auth = yield* McpAuth.Service
const events = yield* EventV2Bridge.Service
const browser = yield* McpBrowser.Service
const policy = {
evaluate: Effect.fnUntraced(function* (action: string, resource: string, fallback: Policy.Effect) {
const config = yield* cfgSvc.get()
return Policy.decision(config.experimental?.policies ?? [], action, resource, fallback)
}),
}
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
@@ -371,7 +378,7 @@ const layer = Layer.effect(
const create = Effect.fn("MCP.create")(
function* (key: string, mcp: ConfigMCPV1.Info) {
if (mcp.enabled === false) {
if (mcp.enabled === false || (yield* policy.evaluate("integration.use", `mcp:${key}`, "allow")) === "deny") {
return DISABLED_RESULT
}
@@ -511,7 +518,10 @@ const layer = Layer.effect(
return
}
if (mcp.enabled === false) {
if (
mcp.enabled === false ||
(yield* policy.evaluate("integration.use", `mcp:${key}`, "allow")) === "deny"
) {
s.status[key] = { status: "disabled" }
return
}
@@ -597,11 +607,17 @@ const layer = Layer.effect(
for (const [key, mcp] of Object.entries(config)) {
if (!isMcpConfigured(mcp)) continue
result[key] = s.status[key] ?? { status: "disabled" }
result[key] =
(yield* policy.evaluate("integration.use", `mcp:${key}`, "allow")) === "deny"
? { status: "disabled" }
: (s.status[key] ?? { status: "disabled" })
}
for (const key of Object.keys(s.config)) {
result[key] = s.status[key] ?? { status: "disabled" }
result[key] =
(yield* policy.evaluate("integration.use", `mcp:${key}`, "allow")) === "deny"
? { status: "disabled" }
: (s.status[key] ?? { status: "disabled" })
}
return result
@@ -609,13 +625,20 @@ const layer = Layer.effect(
const clients = Effect.fn("MCP.clients")(function* () {
const s = yield* InstanceState.get(state)
return s.clients
return Object.fromEntries(
yield* Effect.forEach(Object.entries(s.clients), ([name, client]) =>
policy
.evaluate("integration.use", `mcp:${name}`, "allow")
.pipe(Effect.map((decision) => (decision === "deny" ? [] : [[name, client] as const]))),
).pipe(Effect.map((entries) => entries.flat())),
)
})
const instructions = Effect.fn("MCP.instructions")(function* () {
const s = yield* InstanceState.get(state)
const allowed = new Set(Object.keys(yield* clients()))
return Object.entries(s.instructions)
.filter(([name]) => s.status[name]?.status === "connected")
.filter(([name]) => s.status[name]?.status === "connected" && allowed.has(name))
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, item]) => ({
name,
@@ -672,7 +695,11 @@ const layer = Layer.effect(
const defaultTimeout = cfg.experimental?.mcp_timeout
for (const [clientName, client] of Object.entries(s.clients)) {
if (s.status[clientName]?.status !== "connected") continue
if (
s.status[clientName]?.status !== "connected" ||
(yield* policy.evaluate("integration.use", `mcp:${clientName}`, "allow")) === "deny"
)
continue
const mcpConfig = config[clientName]
const listed = s.defs[clientName]
if (!listed) {
@@ -701,13 +728,25 @@ const layer = Layer.effect(
([name]) => s.status[name]?.status === "connected" && (!targetClientName || name === targetClientName),
),
([clientName, client]) =>
McpCatalog.fetch(
clientName,
client,
(c) => listFn(c, requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout)),
label,
key,
).pipe(Effect.map((items) => Object.entries(items ?? {}))),
policy
.evaluate("integration.use", `mcp:${clientName}`, "allow")
.pipe(
Effect.flatMap((decision) =>
decision === "deny"
? Effect.succeed([])
: McpCatalog.fetch(
clientName,
client,
(c) =>
listFn(
c,
requestTimeout(s, clientName, cfg.mcp?.[clientName], cfg.experimental?.mcp_timeout),
),
label,
key,
).pipe(Effect.map((items) => Object.entries(items ?? {}))),
),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
})
@@ -743,6 +782,7 @@ const layer = Layer.effect(
label: string,
meta?: Record<string, unknown>,
) {
if ((yield* policy.evaluate("integration.use", `mcp:${clientName}`, "allow")) === "deny") return undefined
const s = yield* InstanceState.get(state)
const client = s.clients[clientName]
if (!client) {
@@ -788,6 +828,7 @@ const layer = Layer.effect(
})
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
if ((yield* policy.evaluate("integration.use", `mcp:${mcpName}`, "allow")) === "deny") return undefined
const s = yield* InstanceState.get(state)
if (s.config[mcpName]) return s.config[mcpName]
+12 -1
View File
@@ -1,3 +1,5 @@
import { Policy } from "@opencode-ai/core/policy"
import { fileURLToPath } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import type {
Hooks,
@@ -178,7 +180,16 @@ const layer = Layer.effect(
if (init._tag === "Some") hooks.push(init.value)
}
const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
const plugins = flags.pure
? []
: (cfg.plugin_origins ?? []).filter((origin) => {
const spec = typeof origin.spec === "string" ? origin.spec : origin.spec[0]
const name = spec.startsWith("file://") ? fileURLToPath(spec) : parsePluginSpecifier(spec).pkg
return (
Policy.decision(cfg.experimental?.policies ?? [], "integration.use", `plugin:${name}`, "allow") ===
"allow"
)
})
if (flags.pure && cfg.plugin_origins?.length) {
}
if (plugins.length) yield* config.waitForDependencies()
+41 -1
View File
@@ -14,6 +14,8 @@ import {
type Tool,
} from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Config } from "../../src/config/config"
import { InstanceStore } from "../../src/project/instance-store"
import { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { MCP } from "../../src/mcp/index"
@@ -21,7 +23,7 @@ import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
import { TestInstance } from "../fixture/fixture"
import { pollWithTimeout, testEffect } from "../lib/effect"
const it = testEffect(LayerNode.compile(MCP.node))
const it = testEffect(LayerNode.compile(LayerNode.group([MCP.node, Config.node])))
const stdioFixture = path.join(import.meta.dir, "../fixture/mcp-lifecycle-stdio.ts")
type Page<T> = { items: T[]; nextCursor?: string }
@@ -182,6 +184,44 @@ function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server:
const remote = (url: string, timeout?: number) => ({ type: "remote" as const, url, oauth: false as const, timeout })
it.instance(
"integration policies prevent connections and are respected after a configuration refresh",
() =>
Effect.gen(function* () {
const server = yield* lifecycleServer({ instructions: "Blocked server guidance" })
server.state.prompts = [{ name: "prompt" }]
server.state.resources = [{ name: "resource", uri: "test://resource" }]
const mcp = yield* MCP.Service
const config = yield* Config.Service
expect(statusName((yield* mcp.add("blocked", remote(server.url))).status, "blocked")).toBe("disabled")
expect(server.state.requests).toHaveLength(0)
yield* mcp.add("allowed", remote(server.url))
expect(Object.keys(yield* mcp.clients())).toEqual(["allowed"])
expect(Object.keys(yield* mcp.tools())).not.toHaveLength(0)
const instance = yield* TestInstance
yield* Effect.promise(() =>
Bun.write(
path.join(instance.directory, "opencode.json"),
JSON.stringify({
mcp: { allowed: remote(server.url) },
experimental: { policies: [{ action: "integration.use", resource: "*", effect: "deny" }] },
}),
),
)
yield* config.invalidate()
const instances = yield* InstanceStore.Service
yield* instances.disposeAll()
expect(yield* mcp.clients()).toEqual({})
expect(yield* mcp.tools()).toEqual({})
expect(yield* mcp.instructions()).toEqual([])
expect(yield* mcp.prompts()).toEqual({})
expect(yield* mcp.resources()).toEqual({})
expect(statusName(yield* mcp.status(), "allowed")).toBe("disabled")
expect(yield* mcp.readResource("allowed", "test://resource")).toBeUndefined()
}),
{ config: { experimental: { policies: [{ action: "integration.use", resource: "mcp:blocked", effect: "deny" }] } } },
)
it.instance("advertises and lists the instance directory as its root", () =>
Effect.gen(function* () {
const server = yield* lifecycleServer({ requestRoots: true })
@@ -73,6 +73,28 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo
})
describe("plugin.trigger", () => {
it.instance("does not import a local plugin denied by an integration policy", () =>
Effect.gen(function* () {
const instance = yield* TestInstance
const file = path.join(instance.directory, "plugin.ts")
const marker = path.join(instance.directory, "imported.txt")
yield* Effect.promise(() =>
Bun.write(file, `await Bun.write(${JSON.stringify(marker)}, "loaded"); export default async () => ({})`),
)
yield* Effect.promise(() =>
Bun.write(
path.join(instance.directory, "opencode.json"),
JSON.stringify({
plugin: [pathToFileURL(file).href],
experimental: { policies: [{ action: "integration.use", resource: `plugin:${file}`, effect: "deny" }] },
}),
),
)
expect(yield* triggerSystemTransform()).toEqual([])
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
)
it.instance("runs synchronous hooks without crashing", () =>
withProject(
[