diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 2c6546fd0d5..ba55687b249 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,6 +18,7 @@ import { ConfigNormalize } from "./config/normalize.js" import { ConfigDiscovery } from "./config/discovery.js" import { ConfigWatch } from "./config/watch.js" import { WellKnown } from "./wellknown.js" +import { ConfigExperimental } from "@opencode/schema/config/experimental" import { AppProcess } from "@opencode/util/process" import { ChildProcess } from "effect/process" @@ -241,16 +242,41 @@ export const layer = (options?: Options) => ) : [] - // System entries sit at lowest priority so users and projects can - // override ordinary defaults while reversed policy evaluation gives - // system policies highest file-based authority. Global entries sit - // below explicit and direct files; project directories rank above them. - // MDM preferences rank below the system directory so their policies - // carry the most file-based authority, matching V1 where they won. - const systemSupplementary = [ - ...(sources.preferences ? yield* loadPreferences(sources.preferences).pipe(Effect.orDie) : []), + // Managed settings load last so they override everything, and MDM + // preferences outrank the system directory as in V1. Policies evaluate + // in reverse load order, so managed policies split off and load first + // to keep final file-based authority. + const managed = [ ...(sources.system?.present ? yield* loadDirectory(sources.system.path).pipe(Effect.orDie) : []), + ...(sources.preferences ? yield* loadPreferences(sources.preferences).pipe(Effect.orDie) : []), ] + const managedPolicies = managed + .flatMap((entry) => + entry.type === "document" && entry.info.experimental?.policies + ? [ + new Document({ + type: "document", + path: entry.path, + info: new Info({ + experimental: new ConfigExperimental.Info({ policies: entry.info.experimental.policies }), + }), + }), + ] + : [], + ) + .toReversed() + const managedSettings = managed.map((entry) => + entry.type === "document" && entry.info.experimental?.policies + ? new Document({ + ...entry, + info: new Info({ + ...entry.info, + experimental: new ConfigExperimental.Info({ ...entry.info.experimental, policies: undefined }), + }), + }) + : entry, + ) + // Global entries sit below explicit and direct files; project directories rank above them. const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : [] const projectSupplementary = yield* Effect.forEach( sources.project.filter((root) => root.present), @@ -260,13 +286,14 @@ export const layer = (options?: Options) => Effect.map((entries) => entries.flat()), ) return [ - ...systemSupplementary, + ...managedPolicies, ...(yield* loadWellknown().pipe(Effect.orDie)), ...globalSupplementary, ...explicit, ...direct, ...projectSupplementary, ...content, + ...managedSettings, ] }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index c0d946445d2..47590d9f534 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -23,6 +23,7 @@ import { Provider } from "@opencode/core/provider" import { AbsolutePath } from "@opencode/core/schema" import { WellKnown } from "@opencode/core/wellknown" import { KV } from "@opencode/core/kv" +import { ManagedPolicy } from "@opencode/core/managed-policy" import { Integration } from "@opencode/schema/integration" import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes" import { location } from "../fixture/location" @@ -77,6 +78,8 @@ function testLayer( return Layer.mergeAll(built, watcher) } +const tool = (effect: "allow" | "deny") => ({ action: "tool.use", resource: "shell:*", effect }) + const provider = { package: "native", settings: {}, @@ -202,7 +205,7 @@ describe("Config", () => { ), ) - it.live("loads system configuration at lowest priority", () => + it.live("loads system configuration at highest priority", () => Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( Effect.flatMap((tmp) => { const system = path.join(tmp.path, "system") @@ -214,10 +217,13 @@ describe("Config", () => { await fs.mkdir(project, { recursive: true }) await fs.writeFile( path.join(system, "opencode.json"), - JSON.stringify({ shell: "system", username: "system-user" }), + JSON.stringify({ shell: "system", username: "system-user", experimental: { policies: [tool("deny")] } }), ) await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) - await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + await fs.writeFile( + path.join(project, "opencode.json"), + JSON.stringify({ shell: "project", experimental: { policies: [tool("allow")] } }), + ) }).pipe( Effect.andThen( Effect.gen(function* () { @@ -225,13 +231,16 @@ describe("Config", () => { const entries = yield* config.entries() expect( entries.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])), - ).toEqual(["system", "global", "project"]) + ).toEqual(["global", "project", "system"]) expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([ - AbsolutePath.make(system), AbsolutePath.make(global), + AbsolutePath.make(system), ]) - expect(Config.latest(entries, "shell")).toBe("project") + expect(Config.latest(entries, "shell")).toBe("system") expect(Config.latest(entries, "username")).toBe("system-user") + expect( + ManagedPolicy.decision(ManagedPolicy.statements(entries, { statements: [] }), "tool.use", "shell:ls"), + ).toBe("deny") }).pipe( Effect.provide( testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, { diff --git a/services/www/src/docs/content/config.mdx b/services/www/src/docs/content/config.mdx index 749218a592e..77a56d0d100 100644 --- a/services/www/src/docs/content/config.mdx +++ b/services/www/src/docs/content/config.mdx @@ -27,7 +27,7 @@ OpenCode supports JSON and JSONC. Use JSONC when you want comments or trailing c ## Locations -Put machine-wide defaults in the system configuration directory: +Put managed, machine-wide settings in the system configuration directory: | Platform | Directory | | -------- | --------------------------------------- | @@ -56,9 +56,11 @@ machine-wide one: /Library/Managed Preferences/ai.opencode.managed.plist ``` -Managed preferences load just below the system directory, so their -[policies](/policies#precedence) have the most authority of any file. OpenCode -reads them at startup and whenever another config change triggers a reload. +Managed settings have the highest precedence, so users and projects cannot +override the values they set, and managed preferences override the system +directory. Settings alone cannot stop users from adding providers, MCP servers, +agents, or plugins; use [policies](/policies#precedence) in managed +configuration to enforce restrictions. Put user settings for every project in the global configuration: @@ -95,15 +97,16 @@ For example, start OpenCode from `/home/user/projects/acme/packages/web`: OpenCode applies these files from lowest to highest precedence: -1. `/etc/opencode/opencode.json` -2. `~/.config/opencode/opencode.json` -3. `/home/user/projects/acme/opencode.json` -4. `/home/user/projects/acme/packages/web/opencode.json` +1. `~/.config/opencode/opencode.json` +2. `/home/user/projects/acme/opencode.json` +3. `/home/user/projects/acme/packages/web/opencode.json` +4. `/etc/opencode/opencode.json` The package config overrides matching settings from the repository config, -which overrides the global and system configs. Settings that do not conflict -are preserved, and [policies](/policies#precedence) evaluate in the reverse -order so system policies outrank global and project policies. +which overrides the global config. The system config overrides all of them. +Settings that do not conflict are preserved. [Policies](/policies#precedence) +from global and project files evaluate in the reverse order, and system +policies outrank all of them. ## Schema diff --git a/services/www/src/docs/content/migrate-v1.mdx b/services/www/src/docs/content/migrate-v1.mdx index d4c10e50bfc..42b099a84b0 100644 --- a/services/www/src/docs/content/migrate-v1.mdx +++ b/services/www/src/docs/content/migrate-v1.mdx @@ -65,11 +65,11 @@ V2 reads these same locations. It normalizes supported V1 and native V2 fields i file. Existing supported V1 configuration is intended to keep working, so you do not need to convert it to try or adopt V2. -In V2, the system configuration directory loads at the lowest precedence for ordinary settings so it provides -machine-wide defaults, while [policies](/policies#precedence) evaluate in the reverse order so system -`experimental.policies` (including migrated `enabled_providers` and `disabled_providers`) cannot be overridden by global -or project files. Express admin-enforced restrictions as `experimental.policies` rather than ordinary settings such as -`permissions`. +As in V1, system configuration and macOS managed preferences have the highest precedence, so their settings override +global and project files. Their [policies](/policies#precedence), including migrated `enabled_providers` and +`disabled_providers`, also outrank global and project policies. Overriding a setting cannot stop users from adding +providers, MCP servers, agents, or plugins, so express admin-enforced restrictions as `experimental.policies` rather +than ordinary settings such as `permissions`. ### Ask OpenCode to migrate diff --git a/services/www/src/docs/content/policies.mdx b/services/www/src/docs/content/policies.mdx index d76272381ce..3ef71840747 100644 --- a/services/www/src/docs/content/policies.mdx +++ b/services/www/src/docs/content/policies.mdx @@ -181,7 +181,8 @@ it was configured. ## Precedence Ordinary settings let the nearest configuration win. Policies reverse that: statements -from broader configuration are evaluated later, so they override narrower ones. +from broader configuration are evaluated later, so they override narrower ones. Managed +configuration and the connected Console always have the final say. | Authority | Source | | ----------- | -------------------------------------------------------------------------------------------------------------- |