diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f9ca88341434..a2b75d7458e4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -127,7 +127,58 @@ 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 = {} + + 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) }) + } + } + + 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() + + 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) }) + } + } + } + + return result + } + function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) if (target.instructions && source.instructions) { @@ -136,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 = { waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise } @@ -1541,6 +1615,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) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e759985feb7e..cdd90074723b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -69,7 +69,7 @@ afterEach(async () => { await clear(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)) } @@ -1424,6 +1424,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 load() + 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 load() + 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 load() + 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 load() + 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 load() + 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 load() + 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 load() + 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 load() + 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 load() + 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) => { 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.