mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-08 00:08:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4b9c365fb |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user