fix(core): give managed configuration highest precedence (#55267)

This commit is contained in:
Aiden Cline
2026-10-13 21:28:47 -05:00
committed by GitHub
Unverified
parent 6a7cedcbf1
commit 6dc4d4e758
5 changed files with 72 additions and 32 deletions
+36 -9
View File
@@ -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,
]
})
+15 -6
View File
@@ -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, {
+14 -11
View File
@@ -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
+5 -5
View File
@@ -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
+2 -1
View File
@@ -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 |
| ----------- | -------------------------------------------------------------------------------------------------------------- |