Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}>
}
}
}
Expand Down
36 changes: 16 additions & 20 deletions packages/core/src/config/plugin/policy.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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()) {
Expand All @@ -41,14 +31,20 @@ 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()
const denied = event.resources
.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")
Expand Down
63 changes: 52 additions & 11 deletions packages/core/src/managed-policy.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -12,6 +15,7 @@ export interface State {
}

export interface Interface {
readonly changes: () => Stream.Stream<void>
/** Synchronous so catalog transforms can consult the statements while they run. */
readonly current: () => State
/** Replaces the whole state; statements never merge across connections. */
Expand All @@ -20,15 +24,52 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@opencode/ManagedPolicy") {}

const layer = Layer.sync(Service, () => {
const state: { current: State } = { current: { statements: [] } }
return Service.of({
current: () => state.current,
set: (next) =>
Effect.sync(() => {
state.current = next
}),
})
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const changes = yield* PubSub.unbounded<void>()
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"
)
}
20 changes: 19 additions & 1 deletion packages/core/src/plugin/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -40,7 +44,21 @@ export const make = Effect.fn("PluginModule.make")(function* () {
load: (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
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),
}
})
Expand Down
23 changes: 16 additions & 7 deletions packages/core/src/plugin/provider/opencode.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -462,11 +475,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag

// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(1)).pipe(
Effect.andThen(check()),
Effect.forever,
Effect.forkScoped,
)
yield* Effect.sleep(Duration.minutes(1)).pipe(Effect.andThen(check()), Effect.forever, Effect.forkScoped)
}),
})

Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/plugin/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Cause, Effect, Layer, Queue, Stream } from "effect"
import path from "path"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { ManagedPolicy } from "../managed-policy.js"
import { Bus } from "../bus.js"
import { Npm } from "@opencode/util/npm"
import { Plugin } from "../plugin.js"
Expand Down Expand Up @@ -64,6 +65,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
)
}),
)
if ("blocked" in plugin) {
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.delete(operation.target)
failures.delete(operation.target)
continue
}
if ("pending" in plugin) {
pending.add(operation.target)
continue
Expand Down Expand Up @@ -115,6 +123,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (

export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const managed = yield* ManagedPolicy.Service
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const instance = yield* InstancePlugins.Service
Expand Down Expand Up @@ -150,6 +159,8 @@ export const layer = Layer.effectDiscard(
revision: "internal",
source: { type: "builtin" as const },
}))
// Load the Console connection and its organization policies before importing external modules.
if (current === 1) yield* registry.activate(pre.filter((plugin) => 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)
Expand Down Expand Up @@ -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(
Expand Down
38 changes: 37 additions & 1 deletion packages/core/test/config/normalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
}

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({
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading