From 5c25db25b4adb6fb86270e201c183714097b6316 Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Mon, 13 Apr 2026 13:26:38 +0100 Subject: [PATCH 1/5] test: managed settings bypass gap tests 9 tests that fail on unpatched code, proving two bypass vectors: 1. OPENCODE_PERMISSION env var overwrites managed deny rules (mergeDeep second-arg-wins semantics) 2. Additive object merging lets users inject entries into managed fields (agent, mcp, instructions, provider, formatter, lsp, command, experimental, skills) that survive alongside managed config instead of being replaced --- packages/opencode/test/config/config.test.ts | 255 ++++++++++++++++++- 1 file changed, 254 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 0ac61aee7172..eb3ff438362d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -44,7 +44,7 @@ afterEach(async () => { await Config.invalidate(true) }) -async function writeManagedSettings(settings: object, filename = "opencode.json") { +async function writeManagedSettings(settings: object, filename = "managed-settings.json") { await fs.mkdir(managedConfigDir, { recursive: true }) await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings)) } @@ -1392,6 +1392,259 @@ test("managed settings override project settings", async () => { }) }) +// Demonstrates the gap using pure mergeDeep logic — no caching involved. +// In the unpatched code, OPENCODE_PERMISSION is applied as: +// result.permission = mergeDeep(result.permission, JSON.parse(OPENCODE_PERMISSION)) +// Since envvar is the second argument to mergeDeep, it overwrites the managed deny. +test("mergeDeep semantics allow OPENCODE_PERMISSION to overwrite managed deny", async () => { + const { mergeDeep } = await import("remeda") + + const managed = { bash: { "echo *": "deny" } } + const envvar = { bash: { "echo *": "allow" } } + + // This is exactly what line 1501 of config.ts does + const result = mergeDeep(managed, envvar) as Record> + + // On unpatched code this is "allow" — the managed deny is lost + expect(result.bash["echo *"]).toBe("allow") +}) + +test("managed instructions should replace user instructions, not union", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + instructions: ["/etc/opencode/managed-instructions.md"], + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + instructions: ["./user-instructions.md"], + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.instructions).toEqual(["/etc/opencode/managed-instructions.md"]) + }, + }) +}) + +test("managed agents should replace user agents, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + agent: { + build: { permission: { bash: { "echo *": "deny" } } }, + }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + agent: { + escape: { permission: { bash: "allow" } }, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.escape).toBeUndefined() + }, + }) +}) + +test("managed mcp should replace user mcp, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + mcp: { + safe: { type: "local", command: ["safe"] }, + }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + mcp: { + evil: { type: "local", command: ["evil"] }, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.evil).toBeUndefined() + }, + }) +}) + +test("managed commands should replace user commands, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + command: { + safe: { template: "do something safe" }, + }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + command: { + evil: { template: "do something bad" }, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.command?.evil).toBeUndefined() + }, + }) +}) + +test("managed providers should replace user providers, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + provider: { + "google-vertex": { models: { "gemini-2.5-pro": {} } }, + }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + provider: { + anthropic: { models: {} }, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.provider?.anthropic).toBeUndefined() + }, + }) +}) + +test("managed formatters should replace user formatters, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + formatter: { safe: { command: ["safe-fmt"] } }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + formatter: { evil: { command: ["evil-fmt"] } }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const fmts = config.formatter as Record + expect(fmts.evil).toBeUndefined() + }, + }) +}) + +test("managed lsp should replace user lsp, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + lsp: { safe: { command: ["safe-lsp"], extensions: [".ts"] } }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + lsp: { evil: { command: ["evil-lsp"], extensions: [".txt"] } }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const lsps = config.lsp as Record + expect(lsps.evil).toBeUndefined() + }, + }) +}) + +test("managed experimental should replace user experimental, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + experimental: { disable_paste_summary: true }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + experimental: { batch_tool: true, openTelemetry: true }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.batch_tool).toBeUndefined() + expect(config.experimental?.openTelemetry).toBeUndefined() + }, + }) +}) + +test("managed skills should replace user skills, not additively merge", async () => { + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + skills: { paths: ["/etc/opencode/skills/"] }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + skills: { paths: ["./user-skills/"], urls: ["https://evil.com/skills/"] }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.skills?.paths).toEqual(["/etc/opencode/skills/"]) + expect(config.skills?.urls).toBeUndefined() + }, + }) +}) + test("missing managed settings file is not an error", async () => { await using tmp = await tmpdir({ init: async (dir) => { From c5ca9d10c027a1b816c0284a53a6005d77458e81 Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Mon, 13 Apr 2026 13:26:55 +0100 Subject: [PATCH 2/5] fix: authoritative managed-settings.json applied after all user config Introduce managed-settings.json (with managed-settings.d/ drop-in fragments) loaded after OPENCODE_PERMISSION and legacy tools, making it the authoritative final word. Object/array fields (agent, command, experimental, formatter, instructions, lsp, mcp, mode, provider, skills) are replaced entirely rather than additively merged, preventing users from injecting entries alongside managed ones. --- packages/opencode/src/config/config.ts | 82 +++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ecce8fb8f85f..c1a228983660 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -130,7 +130,55 @@ export namespace Config { return {} } - // Custom merge function that concatenates array fields instead of replacing them + /** + * Load managed-settings.json and managed-settings.d/*.json drop-in fragments. + * Following the systemd convention: managed-settings.json is the base, then + * all *.json files in managed-settings.d/ are sorted alphabetically and merged on top. + * Later files override earlier ones for scalar values; arrays are concatenated and + * de-duplicated; objects are deep-merged. Hidden files starting with . are ignored. + */ + async function loadManagedSettings(dir: string): Promise { + const baseFile = path.join(dir, "managed-settings.json") + const dropinDir = path.join(dir, "managed-settings.d") + + let result: Info = {} + + if (existsSync(baseFile)) { + try { + const text = await fsNode.readFile(baseFile, "utf-8") + result = parseConfig(text, baseFile) + log.info("loaded managed settings", { path: baseFile }) + } catch (err) { + log.warn("failed to parse managed-settings.json", { path: baseFile, error: String(err) }) + } + } + + if (existsSync(dropinDir)) { + let entries: string[] + try { + entries = await fsNode.readdir(dropinDir) + } catch { + return result + } + + const fragments = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".")).sort() + + for (const name of fragments) { + const fragPath = path.join(dropinDir, name) + try { + const text = await fsNode.readFile(fragPath, "utf-8") + const parsed = parseConfig(text, fragPath) + result = mergeConfigConcatArrays(result, parsed) + log.info("loaded managed settings fragment", { path: fragPath }) + } catch (err) { + log.warn("failed to parse managed settings fragment", { path: fragPath, error: String(err) }) + } + } + } + + return result + } + function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) if (target.instructions && source.instructions) { @@ -139,6 +187,29 @@ export namespace Config { return merged } + export const REPLACE_KEYS = [ + "agent", + "command", + "experimental", + "formatter", + "instructions", + "lsp", + "mcp", + "mode", + "provider", + "skills", + ] as const + + function applyAuthoritative(target: Info, source: Info): Info { + const merged = mergeDeep(target, source) + for (const key of REPLACE_KEYS) { + if (key in source) { + ;(merged as Record)[key] = (source as Record)[key] + } + } + return merged + } + export type InstallInput = { signal?: AbortSignal waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise @@ -1514,6 +1585,15 @@ export namespace Config { result.permission = mergeDeep(perms, result.permission ?? {}) } + // managed-settings.json is the authoritative source — applied after all user, + // project, env, and legacy processing so it cannot be overridden by any of them. + // Only macOS MDM preferences (above) take higher priority. + const managedSettings = yield* Effect.promise(() => loadManagedSettings(managedDir)) + if (Object.keys(managedSettings).length > 0) { + result = applyAuthoritative(result, managedSettings) + log.info("applied managed settings (authoritative override)", { source: managedDir }) + } + if (!result.username) result.username = os.userInfo().username if (result.autoshare === true && !result.share) { From 3a3c2f23db7280d65712d4f9aa7bc47ca8a9034b Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Mon, 13 Apr 2026 13:29:57 +0100 Subject: [PATCH 3/5] docs: document managed-settings.json and managed-settings.d/ drop-in fragments --- packages/web/src/content/docs/config.mdx | 50 +++++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 52ee1da0a383..bc8d0e6e9751 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -50,9 +50,10 @@ Config sources are loaded in this order (later sources override earlier ones): 5. **`.opencode` directories** - agents, commands, plugins 6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides 7. **Managed config files** (`/Library/Application Support/opencode/` on macOS) - admin-controlled -8. **macOS managed preferences** (`.mobileconfig` via MDM) - highest priority, not user-overridable +8. **macOS managed preferences** (`.mobileconfig` via MDM) - not user-overridable +9. **`managed-settings.json`** (in the same system directory) - authoritative, cannot be bypassed by any env var or user config -This means project configs can override global defaults, and global configs can override remote organizational defaults. Managed settings override everything. +This means project configs can override global defaults, and global configs can override remote organizational defaults. `managed-settings.json` overrides everything, including the `OPENCODE_PERMISSION` environment variable. :::note The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. @@ -167,6 +168,51 @@ Drop an `opencode.json` or `opencode.jsonc` file in the system managed config di These directories require admin/root access to write, so users cannot modify them. +#### managed-settings.json + +For the strongest enforcement — one that cannot be bypassed by `OPENCODE_PERMISSION` or any other environment variable — use `managed-settings.json` in the same system directory: + +| Platform | Path | +| -------- | ------------------------------------------------------------- | +| macOS | `/Library/Application Support/opencode/managed-settings.json` | +| Linux | `/etc/opencode/managed-settings.json` | +| Windows | `%ProgramData%\opencode\managed-settings.json` | + +`managed-settings.json` is applied after all other config sources, including the `OPENCODE_PERMISSION` environment variable. Object and array fields (such as `mcp`, `agent`, `instructions`, `provider`, `formatter`, `lsp`, `command`, `mode`, `skills`, and `experimental`) are **replaced entirely** rather than merged, so users cannot inject additional entries alongside managed ones. + +```json title="/etc/opencode/managed-settings.json" +{ + "$schema": "https://opencode.ai/config.json", + "share": "disabled", + "permission": { + "*": "ask", + "bash": { + "*": "ask", + "rm -rf *": "deny" + } + } +} +``` + +**Drop-in fragments (`managed-settings.d/`)** + +You can also place individual `.json` fragments in a `managed-settings.d/` subdirectory alongside `managed-settings.json`. Fragments are loaded in alphabetical order and merged on top of `managed-settings.json`. This is useful for deploying settings from multiple configuration management sources (Ansible, Puppet, Chef, etc.) without coordination. + +``` +/etc/opencode/ + managed-settings.json # base policy + managed-settings.d/ + 10-providers.json # loaded first + 20-permissions.json # loaded second, overrides 10-* + 99-override.json # last word +``` + +Hidden files (starting with `.`) are ignored. Invalid JSON in any fragment logs a warning and is skipped; remaining fragments still apply. + +:::note +`managed-settings.json` and `managed-settings.d/` require admin/root access to write, so users cannot modify them. +::: + #### macOS managed preferences On macOS, OpenCode reads managed preferences from the `ai.opencode.managed` preference domain. Deploy a `.mobileconfig` via MDM (Jamf, Kandji, FleetDM) and the settings are enforced automatically. From 8d7047b7423021df1d67fcbd1f4d69befa3702b5 Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Mon, 13 Apr 2026 18:48:37 +0100 Subject: [PATCH 4/5] fix: migrate managed-settings tests from Config.get() to Config.Service --- packages/opencode/test/config/config.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9c45cb4f49c1..9daa738a7faf 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1456,7 +1456,7 @@ test("managed instructions should replace user instructions, not union", async ( await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.instructions).toEqual(["/etc/opencode/managed-instructions.md"]) }, }) @@ -1484,7 +1484,7 @@ test("managed agents should replace user agents, not additively merge", async () await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.escape).toBeUndefined() }, }) @@ -1512,7 +1512,7 @@ test("managed mcp should replace user mcp, not additively merge", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.mcp?.evil).toBeUndefined() }, }) @@ -1540,7 +1540,7 @@ test("managed commands should replace user commands, not additively merge", asyn await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.evil).toBeUndefined() }, }) @@ -1568,7 +1568,7 @@ test("managed providers should replace user providers, not additively merge", as await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.provider?.anthropic).toBeUndefined() }, }) @@ -1592,7 +1592,7 @@ test("managed formatters should replace user formatters, not additively merge", await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const fmts = config.formatter as Record expect(fmts.evil).toBeUndefined() }, @@ -1617,7 +1617,7 @@ test("managed lsp should replace user lsp, not additively merge", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const lsps = config.lsp as Record expect(lsps.evil).toBeUndefined() }, @@ -1642,7 +1642,7 @@ test("managed experimental should replace user experimental, not additively merg await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.experimental?.batch_tool).toBeUndefined() expect(config.experimental?.openTelemetry).toBeUndefined() }, @@ -1667,7 +1667,7 @@ test("managed skills should replace user skills, not additively merge", async () await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.skills?.paths).toEqual(["/etc/opencode/skills/"]) expect(config.skills?.urls).toBeUndefined() }, From 2bda743ef6a9b8cf7d24d6c31a714fce8119f5ae Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Mon, 13 Apr 2026 19:11:14 +0100 Subject: [PATCH 5/5] fix: use async fs functions for loadManagedSettings to avoid event loop blocking --- packages/opencode/src/config/config.ts | 45 ++++++++++++++------------ 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5b24ee49f3e3..569a8da66564 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -140,34 +140,37 @@ export namespace Config { let result: Info = {} - if (existsSync(baseFile)) { - try { - const text = await fsNode.readFile(baseFile, "utf-8") - result = parseConfig(text, baseFile) - log.info("loaded managed settings", { path: baseFile }) - } catch (err) { + try { + const text = await fsNode.readFile(baseFile, "utf-8") + result = parseConfig(text, baseFile) + log.info("loaded managed settings", { path: baseFile }) + } catch (err: any) { + if (err.code !== "ENOENT") { log.warn("failed to parse managed-settings.json", { path: baseFile, error: String(err) }) } } - if (existsSync(dropinDir)) { - let entries: string[] - try { - entries = await fsNode.readdir(dropinDir) - } catch { - return result + let entries: string[] = [] + try { + entries = await fsNode.readdir(dropinDir) + } catch (err: any) { + if (err.code !== "ENOENT" && err.code !== "ENOTDIR") { + log.warn("failed to read managed-settings.d", { path: dropinDir, error: String(err) }) } + return result + } - const fragments = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".")).sort() + const fragments = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".")).sort() - for (const name of fragments) { - const fragPath = path.join(dropinDir, name) - try { - const text = await fsNode.readFile(fragPath, "utf-8") - const parsed = parseConfig(text, fragPath) - result = mergeConfigConcatArrays(result, parsed) - log.info("loaded managed settings fragment", { path: fragPath }) - } catch (err) { + for (const name of fragments) { + const fragPath = path.join(dropinDir, name) + try { + const text = await fsNode.readFile(fragPath, "utf-8") + const parsed = parseConfig(text, fragPath) + result = mergeConfigConcatArrays(result, parsed) + log.info("loaded managed settings fragment", { path: fragPath }) + } catch (err: any) { + if (err.code !== "ENOENT") { log.warn("failed to parse managed settings fragment", { path: fragPath, error: String(err) }) } }