mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 07:16:26 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27a69a9b2e | ||
|
|
a2594ddefb | ||
|
|
3555cef1c8 | ||
|
|
0fa881efd0 | ||
|
|
37ff501cc6 | ||
|
|
fa126d68e8 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -498,7 +498,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -514,7 +514,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -546,7 +546,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -560,7 +560,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -575,7 +575,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -614,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -644,7 +644,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -659,7 +659,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -683,7 +683,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -704,7 +704,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -726,7 +726,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -761,7 +761,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -781,7 +781,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -815,7 +815,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -834,7 +834,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -880,7 +880,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -894,7 +894,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -929,7 +929,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -964,7 +964,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +997,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1038,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
import { databasePath } from "./database-path"
|
||||
|
||||
@@ -159,21 +158,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
yield* Updater.Service.pipe(
|
||||
Effect.flatMap((updater) =>
|
||||
Updater.pollUpdates({
|
||||
check: updater.run().pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (!result) return Effect.void
|
||||
if (result.type === "available") return server.updateAvailable(result.version)
|
||||
return server.updated(result.version)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -24,18 +24,6 @@ export interface Interface {
|
||||
| undefined
|
||||
}
|
||||
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
|
||||
export function decodePolicy(text: string): Policy | undefined {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("polls after 1 minute and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const checks = yield* Queue.unbounded<void>()
|
||||
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Queue.take(checks)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* Queue.take(checks)
|
||||
}),
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2135,7 +2135,7 @@ export type ConfigEntry =
|
||||
experimental?: {
|
||||
portable_shell_scanner?: boolean
|
||||
subagent_depth?: number
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
policies?: Array<{ action: "provider.use" | "permission"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Document } from "@opencode/schema/config"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ManagedPolicy } from "../../managed-policy.js"
|
||||
import { Wildcard } from "../../util/wildcard.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
@@ -11,17 +12,50 @@ export const Plugin = define({
|
||||
id: "opencode.config.policy",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
|
||||
// Authored documents reverse so user-global policy outranks repository policy; organization statements
|
||||
// from the connected Console follow every authored one and have the final say.
|
||||
const policies = () => {
|
||||
const organization = managed.current()
|
||||
return [
|
||||
...loaded.entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
.map((policy) => ({ ...policy, message: "Blocked by configuration policy" })),
|
||||
...organization.statements.map((policy) => ({
|
||||
...policy,
|
||||
message: organization.organization
|
||||
? `Blocked by ${organization.organization}'s policy`
|
||||
: "Blocked by your organization's policy",
|
||||
})),
|
||||
]
|
||||
}
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
const policies = loaded.entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
const current = policies()
|
||||
for (const record of providers.list()) {
|
||||
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
|
||||
const policy = current.findLast(
|
||||
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
|
||||
)
|
||||
if (policy?.effect === "deny") providers.remove(record.provider.id)
|
||||
}
|
||||
})
|
||||
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),
|
||||
),
|
||||
)
|
||||
.find((policy) => policy?.effect === "deny")
|
||||
if (!denied) return
|
||||
event.effect = "deny"
|
||||
event.message = denied.message
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export * as ManagedPolicy from "./managed-policy.js"
|
||||
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
|
||||
/** Policy statements the connected OpenCode Console compiled for whoever it authenticated. */
|
||||
export interface State {
|
||||
readonly statements: ReadonlyArray<ConfigPolicy.Info>
|
||||
/** Organization name for denial messages, when the connection knows it. */
|
||||
readonly organization?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Synchronous so catalog transforms can consult the statements while they run. */
|
||||
readonly current: () => State
|
||||
/** Replaces the whole state; statements never merge across connections. */
|
||||
readonly set: (state: State) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
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 node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
@@ -48,6 +48,7 @@ import { Integration } from "../integration.js"
|
||||
import { Job } from "../job.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { ManagedPolicy } from "../managed-policy.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -89,6 +90,7 @@ import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { ProviderPlugins } from "./provider.js"
|
||||
import { OpencodePlugin } from "./provider/opencode.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { SkillPlugin } from "./skill.js"
|
||||
import { VcsHgPlugin } from "./vcs/hg.js"
|
||||
@@ -121,6 +123,7 @@ const services = [
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
Location.Service,
|
||||
ManagedPolicy.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
Npm.Service,
|
||||
@@ -172,6 +175,7 @@ export const requirements = LayerNode.group([
|
||||
Job.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
ManagedPolicy.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
Npm.node,
|
||||
@@ -252,6 +256,10 @@ const post = [
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
// Repository config must not switch off policy enforcement or the Console connection that delivers
|
||||
// organization statements, so plugin remove operations skip these IDs.
|
||||
export const guarded: ReadonlySet<string> = new Set([OpencodePlugin.id, ConfigPolicyPlugin.Plugin.id])
|
||||
|
||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
// Capture only services; activation supplies the child Scope and batching context.
|
||||
const context = Context.pick(...services)(yield* Effect.context<Requirements>())
|
||||
|
||||
@@ -6,8 +6,11 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { ManagedPolicy } from "../../managed-policy.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
|
||||
@@ -19,6 +22,10 @@ const RemoteResponse = Schema.Struct({
|
||||
websearch: Schema.Struct({
|
||||
providerID: WebSearch.ID,
|
||||
}).pipe(Schema.optional),
|
||||
// Organization policy compiled for the authenticated caller; omitted when there is none.
|
||||
experimental: Schema.Struct({
|
||||
policies: Schema.Array(ConfigPolicy.Info).pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
})
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
@@ -111,32 +118,50 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
}
|
||||
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ManagedPolicy.Service | Scope.Scope>({
|
||||
id: "opencode.provider.opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
let snapshot: {
|
||||
config: typeof RemoteResponse.Type | undefined
|
||||
connection: ActiveConnection
|
||||
} = { config: undefined, connection: undefined }
|
||||
organization: string | undefined
|
||||
} = { config: undefined, connection: undefined, organization: undefined }
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const config = credential
|
||||
? yield* fetchConfig(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
if (!connection) return { config: undefined, connection, organization: undefined }
|
||||
return yield* ctx.integration.connection.resolve(connection).pipe(
|
||||
Effect.flatMap((credential) => {
|
||||
if (!credential) return Effect.succeed({ config: undefined, connection, organization: undefined })
|
||||
return fetchConfig(http, credential).pipe(
|
||||
Effect.map((config) => ({
|
||||
config,
|
||||
connection,
|
||||
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
|
||||
})),
|
||||
)
|
||||
: undefined
|
||||
return { config, connection }
|
||||
}),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
// A load that fails for the connection already in place keeps its last config: dropping it
|
||||
// would lift organization policy while personal credentials keep working.
|
||||
Effect.as(
|
||||
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
|
||||
? { config: snapshot.config, connection, organization: snapshot.organization }
|
||||
: { config: undefined, connection, organization: undefined },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
// Statements ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too.
|
||||
const publish = (next: typeof snapshot) =>
|
||||
managed.set({ statements: next.config?.experimental?.policies ?? [], organization: next.organization })
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.update("opencode", (integration) => {
|
||||
@@ -147,6 +172,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
})
|
||||
|
||||
snapshot = yield* load()
|
||||
yield* publish(snapshot)
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
|
||||
const source = providers.get(item.canonical ?? providerID)
|
||||
@@ -309,6 +335,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
|
||||
snapshot = next
|
||||
yield* publish(next)
|
||||
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
|
||||
|
||||
@@ -39,7 +39,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
if (operation.type === "remove") {
|
||||
if (operation.target === "*") failures.clear()
|
||||
plugins()
|
||||
.filter((plugin) => matches(operation.target, plugin.id))
|
||||
.filter((plugin) => matches(operation.target, plugin.id) && !PluginInternal.guarded.has(plugin.id))
|
||||
.forEach((plugin) => enabled.delete(plugin.id))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Document, Event, Info, type Entry } from "@opencode/schema/config"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { ManagedPolicy } from "@opencode/core/managed-policy"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
@@ -13,15 +18,18 @@ import { PluginTestLayer } from "../plugin/fixture"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
experimental: {
|
||||
policies: items.map((item) => ({ action: "provider.use", ...item })),
|
||||
},
|
||||
}),
|
||||
})
|
||||
const document = (...policies: ConfigPolicy.Info[]) =>
|
||||
new Document({ type: "document", info: decode({ experimental: { policies } }) })
|
||||
const provider = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({
|
||||
action: "provider.use",
|
||||
resource,
|
||||
effect,
|
||||
})
|
||||
const permission = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({
|
||||
action: "permission",
|
||||
resource,
|
||||
effect,
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
@@ -29,6 +37,17 @@ const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
})
|
||||
|
||||
const evaluate = Effect.fn(function* (action: string, resources: string[], effect: Permission.Effect = "allow") {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("permission", "evaluate", {
|
||||
sessionID: Session.ID.make("ses_policy"),
|
||||
action,
|
||||
resources,
|
||||
effect,
|
||||
})
|
||||
return { effect: event.effect, message: event.message }
|
||||
})
|
||||
|
||||
describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -39,11 +58,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
catalog.update(Provider.ID.make("company-internal"), () => {})
|
||||
})
|
||||
yield* addPlugin([
|
||||
policies(
|
||||
{ effect: "deny", resource: "*" },
|
||||
{ effect: "allow", resource: "anthropic" },
|
||||
{ effect: "allow", resource: "company-*" },
|
||||
),
|
||||
document(provider("deny", "*"), provider("allow", "anthropic"), provider("allow", "company-*")),
|
||||
])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
@@ -56,10 +71,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
|
||||
yield* addPlugin([
|
||||
policies({ effect: "deny", resource: "openai" }),
|
||||
policies({ effect: "allow", resource: "openai" }),
|
||||
])
|
||||
yield* addPlugin([document(provider("deny", "openai")), document(provider("allow", "openai"))])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
}),
|
||||
@@ -76,10 +88,122 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host)
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
|
||||
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
|
||||
yield* test.setEntries([document(provider("allow", "openai"))])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* waitUntil(catalog.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
|
||||
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
|
||||
}).pipe(Effect.provide(Config.testLayer([document(provider("deny", "openai"))]))),
|
||||
)
|
||||
|
||||
it.effect("denies permissions matched as action:resource", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "shell:git push *"))])
|
||||
|
||||
expect(yield* evaluate("shell", ["git push"])).toEqual({
|
||||
effect: "deny",
|
||||
message: "Blocked by configuration policy",
|
||||
})
|
||||
expect(yield* evaluate("shell", ["git push origin main"])).toEqual({
|
||||
effect: "deny",
|
||||
message: "Blocked by configuration policy",
|
||||
})
|
||||
// Compound commands check several resources; any denied resource denies the operation.
|
||||
expect((yield* evaluate("shell", ["git status", "git push"])).effect).toBe("deny")
|
||||
expect(yield* evaluate("shell", ["git status"])).toEqual({ effect: "allow", message: undefined })
|
||||
expect(yield* evaluate("edit", ["git push"])).toEqual({ effect: "allow", message: undefined })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("turns an ask into a deny but never grants", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "webfetch:*"), permission("allow", "shell:*"))])
|
||||
|
||||
expect((yield* evaluate("webfetch", ["https://example.com"], "ask")).effect).toBe("deny")
|
||||
expect((yield* evaluate("shell", ["ls"], "ask")).effect).toBe("ask")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets a later allow lift an earlier broad deny", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "shell:*"), permission("allow", "shell:git status *"))])
|
||||
|
||||
expect((yield* evaluate("shell", ["git status --short"])).effect).toBe("allow")
|
||||
expect((yield* evaluate("shell", ["rm -rf /"])).effect).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("denies every permission with a bare wildcard", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "*"))])
|
||||
|
||||
expect((yield* evaluate("question", ["*"])).effect).toBe("deny")
|
||||
expect((yield* evaluate("read", ["/tmp/notes.txt"])).effect).toBe("deny")
|
||||
expect((yield* evaluate("github_delete_repository", ["*"])).effect).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates organization provider statements after every authored document", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.update(Provider.ID.openai, () => {})
|
||||
catalog.update(Provider.ID.anthropic, () => {})
|
||||
catalog.update(Provider.ID.opencode, () => {})
|
||||
})
|
||||
yield* managed.set({ statements: [provider("deny", "*"), provider("allow", "opencode")] })
|
||||
yield* addPlugin([document(provider("allow", "anthropic"))])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined()
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets an organization allow restore a provider denied by the user", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
|
||||
yield* managed.set({ statements: [provider("allow", "openai")] })
|
||||
yield* addPlugin([document(provider("deny", "openai"))])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("names the organization when its permission statement decides", () =>
|
||||
Effect.gen(function* () {
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* managed.set({ statements: [permission("deny", "shell:sudo *")], organization: "Acme" })
|
||||
yield* addPlugin([document(permission("allow", "shell:*"))])
|
||||
|
||||
expect(yield* evaluate("shell", ["sudo ls"])).toEqual({ effect: "deny", message: "Blocked by Acme's policy" })
|
||||
expect((yield* evaluate("shell", ["ls"])).effect).toBe("allow")
|
||||
|
||||
yield* managed.set({ statements: [permission("deny", "shell:sudo *")] })
|
||||
expect(yield* evaluate("shell", ["sudo ls"])).toEqual({
|
||||
effect: "deny",
|
||||
message: "Blocked by your organization's policy",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds the catalog from replaced organization statements", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
|
||||
yield* addPlugin([])
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
|
||||
|
||||
yield* managed.set({ statements: [provider("deny", "openai")] })
|
||||
yield* catalog.reload()
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
|
||||
yield* managed.set({ statements: [] })
|
||||
yield* catalog.reload()
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Generate } from "@opencode/core/generate"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { KV } from "@opencode/core/kv"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { ManagedPolicy } from "@opencode/core/managed-policy"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -85,6 +86,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
ManagedPolicy.node,
|
||||
Mcp.node,
|
||||
Session.node,
|
||||
PersistentPty.node,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM } from "@opencode/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { ManagedPolicy } from "@opencode/core/managed-policy"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
@@ -619,6 +623,158 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enforces organization policy statements from the Console", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
providers: { opencode: {} },
|
||||
experimental: {
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "opencode", effect: "allow" },
|
||||
{ action: "permission", resource: "shell:sudo *", effect: "deny", audience: "ignored" },
|
||||
],
|
||||
unknown: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.anthropic, () => {}))
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: server.url.origin, orgID: "org_test", orgName: "Acme" },
|
||||
}),
|
||||
})
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* OpencodePlugin.effect(host)
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer([])))
|
||||
|
||||
expect(managed.current()).toEqual({
|
||||
statements: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "opencode", effect: "allow" },
|
||||
{ action: "permission", resource: "shell:sudo *", effect: "deny" },
|
||||
],
|
||||
organization: "Acme",
|
||||
})
|
||||
expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined()
|
||||
expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined()
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps policy statements bound to the connected Console account", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state: { status: number; policies: Record<string, unknown[]>; requests: number } = {
|
||||
status: 200,
|
||||
policies: {},
|
||||
requests: 0,
|
||||
}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
state.requests++
|
||||
if (state.status !== 200) return new Response("Unavailable", { status: state.status })
|
||||
const policies = state.policies[request.headers.get("x-org-id") ?? ""]
|
||||
return Response.json({ providers: {}, ...(policies ? { experimental: { policies } } : {}) })
|
||||
},
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const providers = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const rebuilds = { count: 0 }
|
||||
const account = (orgID: string, orgName: string) =>
|
||||
credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: orgID,
|
||||
metadata: { server: server.url.origin, orgID, orgName },
|
||||
}),
|
||||
})
|
||||
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
|
||||
const env: ConfigPolicy.Info = { action: "permission", resource: "edit:*.env", effect: "deny" }
|
||||
yield* providers.transform(() => {
|
||||
rebuilds.count++
|
||||
})
|
||||
const alpha = yield* account("org_alpha", "Alpha")
|
||||
yield* addPlugin()
|
||||
yield* drain
|
||||
const initial = rebuilds.count
|
||||
expect(state.requests).toBe(1)
|
||||
expect(managed.current()).toEqual({ statements: [], organization: "Alpha" })
|
||||
|
||||
state.policies.org_alpha = [sudo]
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(2)
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
|
||||
expect(rebuilds.count).toBe(initial + 1)
|
||||
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(3)
|
||||
expect(rebuilds.count).toBe(initial + 1)
|
||||
|
||||
// An outage for the same connection keeps the last statements instead of lifting them.
|
||||
state.status = 503
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(4)
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
|
||||
expect(rebuilds.count).toBe(initial + 1)
|
||||
|
||||
state.status = 200
|
||||
state.policies.org_beta = [env]
|
||||
const beta = yield* account("org_beta", "Beta")
|
||||
yield* eventually(
|
||||
Effect.sync(() => managed.current()),
|
||||
(current) => current.organization === "Beta",
|
||||
)
|
||||
expect(managed.current()).toEqual({ statements: [env], organization: "Beta" })
|
||||
|
||||
state.status = 404
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(managed.current()).toEqual({ statements: [], organization: "Beta" })
|
||||
|
||||
state.status = 200
|
||||
yield* credentials.remove(beta.id)
|
||||
yield* eventually(
|
||||
Effect.sync(() => managed.current()),
|
||||
(current) => current.organization === "Alpha",
|
||||
)
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
|
||||
|
||||
yield* credentials.remove(alpha.id)
|
||||
yield* eventually(
|
||||
Effect.sync(() => managed.current()),
|
||||
(current) => current.organization === undefined,
|
||||
)
|
||||
expect(managed.current()).toEqual({ statements: [], organization: undefined })
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -32,14 +32,14 @@ const greeter = (command: string, plugin: string = id) =>
|
||||
})
|
||||
|
||||
// Every supervisor activation scans the config plugin operations once, so counting scans counts activations.
|
||||
const source = { activations: 0 }
|
||||
const source: { activations: number; operations: ConfigPluginSource.Operation[] } = { activations: 0, operations: [] }
|
||||
const sourceLayer = Layer.succeed(
|
||||
ConfigPluginSource.Service,
|
||||
ConfigPluginSource.Service.of({
|
||||
operations: () =>
|
||||
Effect.sync(() => {
|
||||
source.activations++
|
||||
return []
|
||||
return source.operations
|
||||
}),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
@@ -202,6 +202,36 @@ describe("PluginSupervisor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const targets of [["opencode.config.policy", "opencode.provider.opencode", "opencode.config.agent"], ["*"]]) {
|
||||
it.effect(`keeps policy enforcement and the Console connection through plugin removals: ${targets}`, () =>
|
||||
Effect.gen(function* () {
|
||||
source.operations = targets.map((target) => ({ type: "remove", target }))
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const inventory = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
const status = (id: string) => inventory.find((plugin) => plugin.id === id)?.state.status
|
||||
expect(status("opencode.config.policy")).toBe("active")
|
||||
expect(status("opencode.provider.opencode")).toBe("active")
|
||||
// Removals still apply to everything else, including instance and builtin plugins.
|
||||
expect(status("opencode.config.agent")).toBeUndefined()
|
||||
expect(inventory.some((plugin) => plugin.id === id)).toBe(targets[0] !== "*")
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
source.operations = []
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("coalesces a burst of reload triggers after the initial generation into one activation", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -13161,7 +13161,7 @@
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["provider.use"]
|
||||
"enum": ["provider.use", "permission"]
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -6,7 +6,7 @@ export const Effect = Schema.Literals(["allow", "deny"])
|
||||
export type Effect = typeof Effect.Type
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
action: Schema.Literal("provider.use"),
|
||||
action: Schema.Literals(["provider.use", "permission"]),
|
||||
resource: Schema.String,
|
||||
effect: Effect,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -11,8 +11,10 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import {
|
||||
createModelPreferenceRepository,
|
||||
cycleModelVariant,
|
||||
favoriteModels,
|
||||
modelPreferenceKey,
|
||||
normalizeModelVariant,
|
||||
recentModels,
|
||||
type ModelPreference,
|
||||
type ModelPreferenceModel,
|
||||
} from "../model-preference"
|
||||
@@ -24,19 +26,6 @@ import { usePermission } from "./permission"
|
||||
import { useLocation } from "./location"
|
||||
import { parse } from "../util/model"
|
||||
|
||||
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
|
||||
const seen = new Set<string>()
|
||||
return [model, ...recent]
|
||||
.filter((item) => {
|
||||
const key = modelPreferenceKey(item)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
|
||||
}
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
@@ -167,37 +156,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const pendingSelectionCommits = new Map<string, { agentID: string; selection: string }>()
|
||||
const selectionKey = (value: ModelSelection) =>
|
||||
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
|
||||
const saveState = {
|
||||
pending: false,
|
||||
}
|
||||
|
||||
function savePreferences() {
|
||||
if (!preferences.ready) {
|
||||
saveState.pending = true
|
||||
return
|
||||
}
|
||||
saveState.pending = false
|
||||
void repository
|
||||
.patch({
|
||||
recent: preferences.recent,
|
||||
favorite: preferences.favorite,
|
||||
variant: preferences.variant,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
repository
|
||||
.load()
|
||||
.then((value) => {
|
||||
function applyPreferences(value: ModelPreference) {
|
||||
batch(() => {
|
||||
setPreferences("recent", value.recent)
|
||||
setPreferences("favorite", value.favorite)
|
||||
setPreferences("variant", value.variant)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setPreferences("ready", true)
|
||||
if (saveState.pending) savePreferences()
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(repository.subscribe(applyPreferences))
|
||||
|
||||
const configuredModel = createMemo(() => {
|
||||
const entry = data.location.config
|
||||
@@ -479,7 +448,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!next) return
|
||||
if (!selectModel({ ...next })) return
|
||||
setPreferences("recent", recentModels(next, preferences.recent))
|
||||
savePreferences()
|
||||
void repository.addRecent(next).catch(() => undefined)
|
||||
},
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
@@ -487,7 +456,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!selectModel(model)) return
|
||||
if (options?.recent) {
|
||||
setPreferences("recent", recentModels(model, preferences.recent))
|
||||
savePreferences()
|
||||
void repository.addRecent(model).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -497,14 +466,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const exists = preferences.favorite.some(
|
||||
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
||||
)
|
||||
const next = exists
|
||||
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
||||
: [model, ...preferences.favorite]
|
||||
setPreferences(
|
||||
"favorite",
|
||||
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
|
||||
)
|
||||
savePreferences()
|
||||
setPreferences("favorite", favoriteModels(model, preferences.favorite, !exists))
|
||||
void repository.setFavorite(model, !exists).catch(() => undefined)
|
||||
})
|
||||
},
|
||||
variant: {
|
||||
@@ -527,7 +490,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||
}
|
||||
setPreferences("variant", modelPreferenceKey(m), value ?? "default")
|
||||
savePreferences()
|
||||
void repository.saveVariant(m, value).catch(() => undefined)
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { readJson, writeJsonAtomic } from "./util/persistence"
|
||||
import { isRecord } from "./util/record"
|
||||
import { Flock } from "@opencode/util/flock"
|
||||
import { watch } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
export type ModelPreferenceModel = {
|
||||
providerID: string
|
||||
@@ -43,6 +46,24 @@ export function modelPreferenceKey(model: ModelPreferenceModel) {
|
||||
return `${model.providerID}/${model.modelID}`
|
||||
}
|
||||
|
||||
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
|
||||
const seen = new Set<string>()
|
||||
return [model, ...recent]
|
||||
.filter((item) => {
|
||||
const key = modelPreferenceKey(item)
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
|
||||
}
|
||||
|
||||
export function favoriteModels(model: ModelPreferenceModel, favorite: ModelPreferenceModel[], enabled: boolean) {
|
||||
const current = favorite.filter((item) => modelPreferenceKey(item) !== modelPreferenceKey(model))
|
||||
return enabled ? [model, ...current] : current
|
||||
}
|
||||
|
||||
export function cycleModelVariant(current: string | undefined, variants: string[]) {
|
||||
const named = variants.filter((variant) => variant !== "default")
|
||||
if (named.length === 0) return undefined
|
||||
@@ -80,32 +101,76 @@ function patch(value: Partial<ModelPreference>) {
|
||||
}
|
||||
|
||||
export function createModelPreferenceRepository(filePath: string) {
|
||||
const state = {
|
||||
pending: Promise.resolve(),
|
||||
}
|
||||
let pending = Promise.resolve()
|
||||
let revision = 0
|
||||
let watcher: ReturnType<typeof watch> | undefined
|
||||
let reload: ReturnType<typeof setTimeout> | undefined
|
||||
const listeners = new Set<(value: ModelPreference) => void>()
|
||||
const read = () =>
|
||||
readJson<unknown>(filePath)
|
||||
.then(decodeModelPreference)
|
||||
.catch(() => decodeModelPreference(undefined))
|
||||
|
||||
function update(change: (current: ModelPreference) => Partial<ModelPreference>) {
|
||||
const result = state.pending.then(async () => {
|
||||
const current = await read()
|
||||
const next = { ...current, ...patch(change(preference(current))) }
|
||||
await writeJsonAtomic(filePath, next)
|
||||
})
|
||||
state.pending = result.catch(() => undefined)
|
||||
const result = pending.then(() =>
|
||||
Flock.withLock(
|
||||
filePath,
|
||||
async () => {
|
||||
const current = await read()
|
||||
await writeJsonAtomic(filePath, { ...current, ...patch(change(preference(current))) })
|
||||
},
|
||||
{ dir: path.join(path.dirname(filePath), "locks") },
|
||||
),
|
||||
)
|
||||
pending = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function load() {
|
||||
return state.pending.then(read).then(preference)
|
||||
return pending.then(read).then(preference)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const current = ++revision
|
||||
const value = await load()
|
||||
if (current !== revision) return
|
||||
listeners.forEach((listener) => listener(value))
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
patch(value: Partial<ModelPreference>) {
|
||||
return update(() => value)
|
||||
addRecent(model: ModelPreferenceModel) {
|
||||
return update((current) => ({ recent: recentModels(model, current.recent) }))
|
||||
},
|
||||
setFavorite(model: ModelPreferenceModel, enabled: boolean) {
|
||||
return update((current) => ({ favorite: favoriteModels(model, current.favorite, enabled) }))
|
||||
},
|
||||
subscribe(listener: (value: ModelPreference) => void) {
|
||||
listeners.add(listener)
|
||||
void refresh()
|
||||
if (!watcher) {
|
||||
watcher = watch(path.dirname(filePath), (_event, filename) => {
|
||||
const changed = filename?.toString()
|
||||
const name = path.basename(filePath)
|
||||
if (changed !== undefined && changed !== name && !changed.startsWith(name + ".")) return
|
||||
clearTimeout(reload)
|
||||
reload = setTimeout(() => void refresh(), 50)
|
||||
})
|
||||
watcher.on("error", () => {
|
||||
watcher?.close()
|
||||
watcher = undefined
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size > 0) return
|
||||
clearTimeout(reload)
|
||||
watcher?.close()
|
||||
watcher = undefined
|
||||
}
|
||||
},
|
||||
async resolveVariant(model: ModelPreferenceModel) {
|
||||
return normalizeModelVariant((await load()).variant[modelPreferenceKey(model)])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { recentModels } from "../../src/context/local"
|
||||
import { recentModels } from "../../src/model-preference"
|
||||
|
||||
test("moves a model to the front, deduplicates, and limits recents", () => {
|
||||
const recent = Array.from({ length: 12 }, (_, index) => ({
|
||||
|
||||
@@ -19,7 +19,7 @@ test("repairs known model preferences and preserves unrelated fields", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("atomically serializes patches and variant updates", async () => {
|
||||
test("atomically serializes model preference updates", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "model.json")
|
||||
await Bun.write(file, JSON.stringify({ unrelated: "keep", favorite: [], variant: {} }))
|
||||
@@ -28,7 +28,7 @@ test("atomically serializes patches and variant updates", async () => {
|
||||
const anthropic = { providerID: "anthropic", modelID: "claude/sonnet" }
|
||||
|
||||
await Promise.all([
|
||||
repository.patch({ recent: [openai] }),
|
||||
repository.addRecent(openai),
|
||||
repository.saveVariant(openai, "high"),
|
||||
repository.saveVariant(anthropic, "low"),
|
||||
])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -10,12 +10,6 @@ const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
const tag = `v${Script.version}`
|
||||
|
||||
if (Script.channel === "beta" && Script.release) {
|
||||
console.log("\n=== desktop beta release ===\n")
|
||||
await $`bun ./packages/desktop/scripts/publish.ts`
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const pkgjsons = await Array.fromAsync(
|
||||
new Bun.Glob("**/package.json").scan({
|
||||
absolute: true,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -13161,7 +13161,7 @@
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["provider.use"]
|
||||
"enum": ["provider.use", "permission"]
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
|
||||
@@ -13161,7 +13161,7 @@
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["provider.use"]
|
||||
"enum": ["provider.use", "permission"]
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
|
||||
@@ -179,6 +179,26 @@ matching resource.
|
||||
|
||||
See the [permissions guide](/permissions) for rule matching and available actions.
|
||||
|
||||
### Policies
|
||||
|
||||
Allow or deny use of a provider, or hard-deny a permission check, with ordered
|
||||
statements that broader configuration can override.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "action": "provider.use", "resource": "*", "effect": "deny" },
|
||||
{ "action": "provider.use", "resource": "anthropic", "effect": "allow" },
|
||||
{ "action": "permission", "resource": "shell:git push *", "effect": "deny" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See the [policies guide](/policies) for matching, precedence across configuration
|
||||
files, and Console-managed policy.
|
||||
|
||||
### Agents
|
||||
|
||||
Override built-in agents or define specialized agents with their own model,
|
||||
|
||||
@@ -11,3 +11,10 @@ using OpenCode particularly as a team
|
||||
- Deploy team wide policies to control OpenCode behavior
|
||||
- Web search
|
||||
- OpenCode Go, $10 subscription for open source model access
|
||||
|
||||
## Policies
|
||||
|
||||
Providers and Tools rules from the Policies page apply to every OpenCode V2 client
|
||||
connected to the workspace. They are delivered as [policy](/policies#console)
|
||||
statements with final authority over the member's own configuration and take effect
|
||||
within about a minute.
|
||||
|
||||
@@ -415,7 +415,8 @@ Most fields that keep the same shape, including `shell`, `model`, `default_agent
|
||||
V2 accepts and preserves `lsp` configuration, but it does not run language servers, expose LSP tools, or produce LSP
|
||||
diagnostics. Replace workflows that depend on those capabilities with the project's lint, typecheck, or compiler commands.
|
||||
|
||||
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
|
||||
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported through
|
||||
[policies](/policies):
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
|
||||
@@ -277,3 +277,19 @@ Review broad approvals and remove those no longer needed.
|
||||
|
||||
Clients may attach feedback when rejecting. Non-interactive clients must decide
|
||||
how to handle approval requests; configured `deny` rules always remain enforced.
|
||||
|
||||
## Policies
|
||||
|
||||
A [policy](/policies) can hard-deny a permission check after these rules and saved
|
||||
approvals run. It turns `allow` or `ask` into `deny` and never grants access.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [{ "action": "permission", "resource": "shell:sudo *", "effect": "deny" }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Global and Console-managed policies override project configuration, which is how an
|
||||
organization blocks a command that a repository would allow.
|
||||
|
||||
@@ -76,6 +76,11 @@ use `.*` to match an ID prefix. A later ID re-enables a plugin.
|
||||
}
|
||||
```
|
||||
|
||||
Two built-in plugins ignore removals so that a repository cannot switch off
|
||||
[policy](/policies) enforcement: `opencode.config.policy` and
|
||||
`opencode.provider.opencode`, the Console connection that delivers organization
|
||||
policy.
|
||||
|
||||
## Manage
|
||||
|
||||
Install, list, check, update, or remove global package plugins with the CLI.
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
title: "Policies"
|
||||
description: "Allow or deny OpenCode actions on named resources, with organization policy taking final authority."
|
||||
---
|
||||
|
||||
Policies decide whether OpenCode may perform an action on a named resource. They are
|
||||
authored under `experimental.policies` and can also be delivered by a connected
|
||||
[OpenCode Console](/console) workspace.
|
||||
|
||||
Policies are separate from [permissions](/permissions). Permissions are per-agent tool
|
||||
rules that can `allow`, `deny`, or `ask`. Policies are binary, never prompt, and only
|
||||
ever tighten what permissions and providers would otherwise allow.
|
||||
|
||||
## Configure
|
||||
|
||||
Deny one provider everywhere by adding a statement to `opencode.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"experimental": {
|
||||
"policies": [{ "action": "provider.use", "resource": "openai", "effect": "deny" }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A denied provider disappears from the catalog and model selection even when it has
|
||||
valid credentials.
|
||||
|
||||
## Statements
|
||||
|
||||
Each statement has three fields:
|
||||
|
||||
| Field | Values | Meaning |
|
||||
| ---------- | ---------------------------- | ---------------------------------------------------- |
|
||||
| `action` | `provider.use`, `permission` | The operation being controlled |
|
||||
| `resource` | string or wildcard pattern | What the statement applies to; depends on the action |
|
||||
| `effect` | `allow`, `deny` | The decision when this statement matches |
|
||||
|
||||
Statements that fail validation are dropped with a warning in the server log and
|
||||
the rest of the list still applies. Check the log after editing a `deny`.
|
||||
|
||||
## Matching
|
||||
|
||||
Resources use the same whole-value wildcards as permissions: `*` matches zero or
|
||||
more characters and `?` matches one.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [{ "action": "provider.use", "resource": "company-*", "effect": "deny" }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This denies `company-us` and `company-eu`. A pattern ending in ` *` also matches the
|
||||
value without arguments, so `shell:git push *` covers `git push` on its own.
|
||||
|
||||
## Order
|
||||
|
||||
When several statements match, the last one wins. There is no specificity rule, so
|
||||
put broad statements first and exceptions after them. If nothing matches, the
|
||||
action is allowed.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "action": "provider.use", "resource": "*", "effect": "deny" },
|
||||
{ "action": "provider.use", "resource": "anthropic", "effect": "allow" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Only Anthropic remains available.
|
||||
|
||||
## Providers
|
||||
|
||||
`provider.use` controls whether a provider is usable at all. The resource is the
|
||||
provider ID: `anthropic`, `openai`, `opencode`, or a custom ID from
|
||||
[`providers`](/providers).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"providers": {
|
||||
"company-ai": {
|
||||
"endpoint": { "type": "openai/responses", "url": "https://ai.company.example/v1/responses" },
|
||||
},
|
||||
},
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "action": "provider.use", "resource": "*", "effect": "deny" },
|
||||
{ "action": "provider.use", "resource": "company-ai", "effect": "allow" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `providers` entry configures the endpoint; the statements make it the only
|
||||
provider OpenCode will use. Policy applies however a provider became known: catalog
|
||||
data, environment variables, saved accounts, built-in plugins, or configuration.
|
||||
|
||||
Use `provider.use` instead of the V1 `enabled_providers` and `disabled_providers`
|
||||
lists. V1 files still load; see
|
||||
[Migrate from V1](/migrate-v1#supported-fields-without-direct-native-equivalents).
|
||||
|
||||
## Permissions
|
||||
|
||||
`permission` statements hard-deny a [permission](/permissions) check. The resource is
|
||||
`<action>:<value>`, matched against every resource the tool checks.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "action": "permission", "resource": "shell:git push *", "effect": "deny" },
|
||||
{ "action": "permission", "resource": "edit:*.env", "effect": "deny" },
|
||||
{ "action": "permission", "resource": "webfetch:*", "effect": "deny" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A denied check fails with `Blocked by configuration policy` instead of prompting. It
|
||||
applies after agent rules and saved approvals, so it overrides an `ask` and an
|
||||
"Allow always" approval alike.
|
||||
|
||||
| Pattern | Blocks |
|
||||
| ---------------------------- | ------------------------------------------- |
|
||||
| `shell:*` | Every shell command |
|
||||
| `read:*/.ssh/*` | Reading SSH material |
|
||||
| `external_directory:*` | Any access outside the Location or worktree |
|
||||
| `subagent:general` | Launching one subagent |
|
||||
| `github_delete_repository:*` | One MCP tool |
|
||||
| `*` | Everything not already denied |
|
||||
|
||||
A `permission` statement with `allow` never grants access. It only lifts an earlier
|
||||
broader `deny`, after which the agent's own rules decide.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "action": "permission", "resource": "shell:*", "effect": "deny" },
|
||||
{ "action": "permission", "resource": "shell:git status *", "effect": "allow" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`git status` falls back to the agent's `shell` rule; every other command is blocked.
|
||||
|
||||
## Precedence
|
||||
|
||||
Ordinary settings let the nearest configuration win. Policies reverse that: statements
|
||||
from broader configuration are evaluated later, so they override narrower ones.
|
||||
|
||||
| Authority | Source |
|
||||
| ----------- | --------------------------------------------------------------- |
|
||||
| 1 (highest) | Connected OpenCode Console workspace |
|
||||
| 2 | Global `~/.config/opencode/opencode.json(c)` |
|
||||
| 3 | Direct `opencode.json(c)`; outer directories beat inner ones |
|
||||
| 4 (lowest) | `.opencode/opencode.json(c)`; outer directories beat inner ones |
|
||||
|
||||
A repository cannot re-enable a provider you deny globally:
|
||||
|
||||
```jsonc title="~/.config/opencode/opencode.jsonc"
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [{ "action": "provider.use", "resource": "openai", "effect": "deny" }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc title="project/opencode.jsonc"
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [{ "action": "provider.use", "resource": "openai", "effect": "allow" }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`openai` stays denied. Within one file, statements keep their written order.
|
||||
|
||||
## Console
|
||||
|
||||
A workspace on the [OpenCode Console](/console) compiles its Providers and Tools
|
||||
policies into statements and returns them with the workspace configuration. OpenCode
|
||||
appends them after every authored statement, so they have the final say: an
|
||||
organization `deny` cannot be lifted by a repository or user `allow`, and an
|
||||
organization `allow` lifts a lower-authority `deny`.
|
||||
|
||||
Denials from these statements name the workspace:
|
||||
|
||||
```text
|
||||
Blocked by Acme's policy
|
||||
```
|
||||
|
||||
Statements follow the connected account.
|
||||
|
||||
| Event | Result |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| Console policy changes | Applied on the next fetch, about once a minute |
|
||||
| Switch to another workspace | Statements replaced with that workspace's |
|
||||
| Disconnect | Statements cleared |
|
||||
| Console unreachable | Last statements for that connection stay in effect |
|
||||
|
||||
The Console plugin and the policy plugin cannot be disabled from `plugins`; see
|
||||
[plugin control](/plugins#control).
|
||||
|
||||
<Callout type="note">
|
||||
Policies do not configure endpoints or credentials, and they cannot make an unusable provider usable. They also do not
|
||||
sandbox plugin code: a denied provider is blocked on the normal provider and model path.
|
||||
</Callout>
|
||||
@@ -49,6 +49,7 @@ export const docsSections: DocsSection[] = [
|
||||
{ title: "Tools", slug: "tools" },
|
||||
{ title: "MCP servers", slug: "mcp-servers" },
|
||||
{ title: "Permissions", slug: "permissions" },
|
||||
{ title: "Policies", slug: "policies" },
|
||||
{ title: "Instructions", slug: "instructions" },
|
||||
{ title: "Sharing", slug: "sharing" },
|
||||
{ title: "Warming", slug: "warming" },
|
||||
|
||||
+4
-4
@@ -26,10 +26,10 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ
|
||||
|
||||
## Decision Records
|
||||
|
||||
| Document | Status | Job |
|
||||
| ---------------------------------------------- | ------------------------ | ------------------------------------------------------------------------ |
|
||||
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
|
||||
| [Provider policy](./provider-policy.md) | Accepted and implemented | Record provider authorization independently from provider configuration. |
|
||||
| Document | Status | Job |
|
||||
| ---------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
|
||||
| [Provider policy](./provider-policy.md) | Accepted and implemented | Allow or deny provider use and permission checks, with Console-managed statements having final authority. |
|
||||
|
||||
## Historical Context
|
||||
|
||||
|
||||
+66
-14
@@ -4,13 +4,16 @@ Status: **Implemented.**
|
||||
|
||||
## Purpose
|
||||
|
||||
Policies control whether an operation on a named resource is allowed. Statements are authored in configuration files and applied by a terminal catalog plugin.
|
||||
Policies control whether an operation on a named resource is allowed. Statements are authored in configuration files or delivered by the connected OpenCode Console, and applied by a terminal plugin.
|
||||
|
||||
The first policy consumer is provider availability:
|
||||
Two consumers exist:
|
||||
|
||||
```text
|
||||
action: provider.use
|
||||
resource: provider ID, such as openai or company-ai
|
||||
|
||||
action: permission
|
||||
resource: <permission action>:<resource>, such as shell:sudo * or edit:*.env
|
||||
```
|
||||
|
||||
Provider configuration and provider policy remain separate:
|
||||
@@ -34,7 +37,7 @@ A provider can be correctly configured and have valid credentials while policy s
|
||||
- Policies do not configure endpoints, credentials, models, or provider options.
|
||||
- Policies do not make unusable resources usable.
|
||||
- Policies do not currently provide conditions, principals, approval prompts, or enforced configuration values.
|
||||
- This spec does not define how organization-managed policies are delivered.
|
||||
- A `permission` statement never grants access; permissions and saved approvals still decide `allow` versus `ask`.
|
||||
|
||||
## Statement Shape
|
||||
|
||||
@@ -55,12 +58,12 @@ A provider can be correctly configured and have valid credentials while policy s
|
||||
```ts
|
||||
interface PolicyInfo {
|
||||
effect: "allow" | "deny"
|
||||
action: string
|
||||
action: "provider.use" | "permission"
|
||||
resource: string
|
||||
}
|
||||
```
|
||||
|
||||
`ConfigPolicy` owns the statement schema. The policy plugin interprets the supported `provider.use` action after all other catalog transforms have run.
|
||||
`ConfigPolicy` owns the statement schema; `action` is a closed set and a statement with any other value is dropped during normalization with a diagnostic. The policy plugin interprets `provider.use` after all other catalog transforms have run and `permission` after every other permission evaluation hook.
|
||||
|
||||
## Matching
|
||||
|
||||
@@ -68,11 +71,12 @@ Both `action` and `resource` use opencode's existing wildcard matching behavior.
|
||||
|
||||
Examples:
|
||||
|
||||
| Action | Resource | Matches |
|
||||
| -------------- | ----------- | ---------------------------------------------------------------------------- |
|
||||
| `provider.use` | `openai` | Only use of provider ID `openai` |
|
||||
| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` |
|
||||
| `provider.*` | `*` | Any provider operation on any provider, if more actions are introduced later |
|
||||
| Action | Resource | Matches |
|
||||
| -------------- | ------------------ | --------------------------------------------------------------- |
|
||||
| `provider.use` | `openai` | Only use of provider ID `openai` |
|
||||
| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` |
|
||||
| `permission` | `shell:git push *` | The `shell` permission for `git push` with or without arguments |
|
||||
| `permission` | `*` | Every permission check on every resource |
|
||||
|
||||
No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result.
|
||||
|
||||
@@ -193,16 +197,64 @@ The relative policy precedence of direct project files and `.opencode` files is
|
||||
|
||||
## Organization-Managed Policy
|
||||
|
||||
Organization-managed policy is not ordinary authored config. When implemented, managed statements must be appended after the reversed authored statements so they have final authority.
|
||||
Organization-managed policy is not ordinary authored config. Managed statements are appended after the reversed authored statements so they have final authority: an organization `deny` cannot be lifted by a repository or user `allow`, and an organization `allow` lifts a lower-authority `deny`.
|
||||
|
||||
```text
|
||||
repository policy -> user-global policy -> organization-managed policy
|
||||
```
|
||||
|
||||
### Delivery
|
||||
|
||||
The OpenCode Console compiles a workspace's Providers and Tools policies into statements for the authenticated caller and returns them from `GET /api/v2/config` alongside managed providers:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"providers": { "opencode": {} },
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "action": "provider.use", "resource": "*", "effect": "deny" },
|
||||
{ "action": "provider.use", "resource": "opencode", "effect": "allow" },
|
||||
{ "action": "permission", "resource": "shell:sudo *", "effect": "deny" },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `experimental` is omitted when the caller has no statements; omission and an empty array are equivalent.
|
||||
- The list is per caller and its order is significant. The client stores it exactly as received; it never reorders, dedupes, or normalizes statements.
|
||||
- `ManagedPolicy` (`packages/core/src/managed-policy.ts`) is the process-global home for the current statements and the organization name. The Console plugin (`opencode.provider.opencode`) writes it whenever its config snapshot is applied; the policy plugin reads it synchronously when evaluating.
|
||||
- Statements ride on the Console plugin's snapshot, so they follow the connection: a credential switch replaces them, and a disconnect or a 404 from the Console clears them. Statements from different connections never merge.
|
||||
- Freshness is the snapshot's freshness: the next poll (about one minute) or the next credential switch.
|
||||
|
||||
### Failure
|
||||
|
||||
A config fetch or credential refresh that fails for the connection already in place keeps that connection's last config, providers and statements alike, and logs a warning. Dropping the config would fail closed for managed providers but open for policy, because a member's personal credentials keep working while the organization's restrictions vanish. A disconnect, a credential switch, or a 404 still replaces the snapshot. There is no durable offline cache.
|
||||
|
||||
### Messages
|
||||
|
||||
When the deciding `permission` statement is organization-managed, the denial reads `Blocked by <organization>'s policy`, or `Blocked by your organization's policy` when the connection has no organization name. Authored statements produce `Blocked by configuration policy`.
|
||||
|
||||
### Protection
|
||||
|
||||
Plugins must not be allowed to add, remove, or override policy statements. Plugins can contribute functionality or configured providers; policy determines whether opencode permits an operation through its managed execution paths.
|
||||
|
||||
Plugin `remove` operations in config ignore `opencode.config.policy` and `opencode.provider.opencode`, whatever the selector (`-*`, `-opencode.*`, or the exact ID). Otherwise a repository could switch off enforcement or the fetch that delivers organization statements.
|
||||
|
||||
Provider policy is not a full sandbox for executable plugins. A denied provider must not be usable through the normal provider/model path, but arbitrary plugin code requires separate governance if that becomes a compliance requirement.
|
||||
|
||||
## Permission Policy
|
||||
|
||||
`permission` statements run in the `permission.evaluate` hook after agent and session rules, saved approvals, and every other plugin's hook. For each resource the tool checks, the string `<action>:<resource>` is matched against the statement resource; if the last matching statement for any resource is `deny`, the evaluation becomes `deny` with the message above.
|
||||
|
||||
```text
|
||||
permission / shell:sudo ls -> deny (statement shell:sudo *)
|
||||
permission / shell:git status -> unchanged: the agent's rules decide allow or ask
|
||||
```
|
||||
|
||||
- A configured `deny` from agent or session rules already denies before the hook runs.
|
||||
- A statement `deny` overrides `allow` and `ask`, including saved "Allow always" approvals.
|
||||
- A statement `allow` never grants; it only cancels an earlier, broader statement `deny`.
|
||||
|
||||
## Interaction With Provider Configuration
|
||||
|
||||
```jsonc
|
||||
@@ -240,12 +292,12 @@ Provider records and model overrides are assembled before checking provider poli
|
||||
|
||||
Flow:
|
||||
|
||||
1. Build provider/model catalog entries.
|
||||
1. Build provider/model catalog entries, including providers managed by the connected Console.
|
||||
2. Apply configured provider and model overrides.
|
||||
3. Run the terminal config policy transform.
|
||||
3. Run the terminal config policy transform over the reversed authored statements followed by the organization-managed statements.
|
||||
4. Remove providers denied by the final matching `provider.use` statement.
|
||||
|
||||
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog.
|
||||
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog. A changed Console snapshot rebuilds the catalog through the Console plugin's own reload, which re-runs the terminal transform.
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
|
||||
Reference in New Issue
Block a user