From 809e1e53478a648627b2b1b4a469f55e72714e2b Mon Sep 17 00:00:00 2001 From: Andrew Suffield Date: Fri, 17 Apr 2026 20:53:05 -0400 Subject: [PATCH 1/4] fix: merging permissions objects will discard ordering. instead, preserve permissions objects as layers, convert them individually to rulesets, and merge the rulesets (#16157) --- packages/opencode/src/agent/agent.ts | 7 +- packages/opencode/src/config/config.ts | 16 ++- packages/opencode/test/config/config.test.ts | 129 ++++++++++++++++++- 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 355718b6bf..0d5e12777c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,4 +1,4 @@ -import { Config } from "../config" +import { Config, ConfigPermission } from "../config" import z from "zod" import { Provider } from "../provider" import { ModelID, ProviderID } from "../provider/schema" @@ -103,7 +103,10 @@ export const layer = Layer.effect( }, }) - const user = Permission.fromConfig(cfg.permission ?? {}) + // Convert permission layers to rulesets and merge them + // Each layer's rules come after the previous, so later configs override earlier ones + const layers = (cfg.permission_layers ?? []) as ConfigPermission.Info[] + const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) const agents: Record = { build: { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7fe337176a..df19cad10c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -51,6 +51,11 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } + // Accumulate permission layers for later merging as rulesets + // This preserves the ordering semantics: later rules override earlier rules + if (source.permission) { + merged.permission_layers = [...(target.permission_layers ?? []), source.permission] + } return merged } @@ -190,6 +195,9 @@ export const InfoSchema = Schema.Struct({ }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(PermissionRef), + permission_layers: Schema.optional(Schema.Any.annotate({ [ZodOverride]: z.array(ConfigPermission.Info) })).annotate({ + description: "Internal: permission configs from each source for layered merging", + }), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), enterprise: Schema.optional( Schema.Struct({ @@ -653,11 +661,12 @@ export const layer = Layer.effect( } if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info + result.permission_layers = [...(result.permission_layers ?? []), envPermission] } if (result.tools) { - const perms: Record = {} + const perms: ConfigPermission.Info = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: ConfigPermission.Action = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch") { @@ -666,7 +675,8 @@ export const layer = Layer.effect( } perms[tool] = action } - result.permission = mergeDeep(perms, result.permission ?? {}) + // Tools permissions come before other permissions (they can be overridden) + result.permission_layers = [perms, ...(result.permission_layers ?? [])] } if (!result.username) result.username = os.userInfo().username diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 3fafdadaa6..94ff4afe86 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,8 +1,9 @@ import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test" import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Config, ConfigManaged } from "../../src/config" +import { Config, ConfigManaged, ConfigPermission } from "../../src/config" import { ConfigParse } from "../../src/config/parse" +import { Permission } from "../../src/permission" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { Instance } from "../../src/project/instance" @@ -1538,6 +1539,132 @@ test("permission config preserves key order", async () => { }) }) +// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it +test("user top-level catchall overrides inherited bash rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + "*": "ask", + bash: { "ls *": "allow" }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + // Use permission_layers for correct ordering (each layer's rules come after previous) + const layers = (config.permission_layers ?? []) as ConfigPermission.Info[] + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask") + }, + }) +}) + +// No top-level catchall, so global bash "rm *" deny is preserved +test("inherited bash rules apply when no user top-level catchall", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "ls *": "allow" }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + // Use permission_layers for correct ordering + const layers = (config.permission_layers ?? []) as ConfigPermission.Info[] + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + }, + }) +}) + +// User's bash "*" catchall overrides global "rm *" deny +test("user bash catchall overrides inherited bash rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "rm *": "deny" }, + }, + }), + ) + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + bash: { "*": "ask", "ls *": "allow" }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + // Use permission_layers for correct ordering + const layers = (config.permission_layers ?? []) as ConfigPermission.Info[] + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow") + expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask") + + // Non-bash permissions should use the top-level "*" rule + expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask") + }, + }) +}) + // MCP config merging tests test("project config can override MCP server enabled status", async () => { From d4e3106fcadaa2a9986cc9dfe6d8100d9dd1ed0c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 12 May 2026 01:30:10 -0500 Subject: [PATCH 2/4] refactor: fold permission_layers into permission as a union Replace the internal permission_layers config field with a union on permission itself (single object or array of layered configs). Add ConfigPermission.toLayers to normalise at consumption sites. Schema has no decode transform, so user files round-trip through Config.update / updateGlobal without their permission section being rewritten into array form. --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/config/config.ts | 20 +++--- packages/opencode/src/config/permission.ts | 8 +++ packages/opencode/test/config/config.test.ts | 68 ++++++++++++++----- .../opencode/test/permission-task.test.ts | 25 +++++-- 5 files changed, 92 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index fc0b645ab5..1f0579b0db 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -119,7 +119,7 @@ export const layer = Layer.effect( // Convert permission layers to rulesets and merge them // Each layer's rules come after the previous, so later configs override earlier ones - const layers = (cfg.permission_layers ?? []) as ConfigPermission.Info[] + const layers = ConfigPermission.toLayers(cfg.permission) const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) const agents: Record = { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 12127d5090..dec1397a75 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -56,10 +56,12 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } - // Accumulate permission layers for later merging as rulesets - // This preserves the ordering semantics: later rules override earlier rules + // Accumulate permission layers for later merging as rulesets. + // This preserves the ordering semantics: later rules override earlier rules. + // Each layer keeps the raw shape the user wrote on disk; consumers should use + // ConfigPermission.toLayers to normalise. if (source.permission) { - merged.permission_layers = [...(target.permission_layers ?? []), source.permission] + merged.permission = [...ConfigPermission.toLayers(target.permission), ...ConfigPermission.toLayers(source.permission)] } return merged } @@ -234,9 +236,11 @@ export const Info = Schema.Struct({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional(ConfigPermission.Info), - permission_layers: Schema.optional(Schema.mutable(Schema.Array(ConfigPermission.Info))).annotate({ - description: "Internal: permission configs from each source for layered merging", + permission: Schema.optional( + Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]), + ).annotate({ + description: + "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.", }), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), attachment: Schema.optional(ConfigAttachment.Info).annotate({ @@ -717,7 +721,7 @@ export const layer = Layer.effect( if (Flag.OPENCODE_PERMISSION) { const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info - result.permission_layers = [...(result.permission_layers ?? []), envPermission] + result.permission = [...ConfigPermission.toLayers(result.permission), envPermission] } if (result.tools) { @@ -731,7 +735,7 @@ export const layer = Layer.effect( perms[tool] = action } // Tools permissions come before other permissions (they can be overridden) - result.permission_layers = [perms, ...(result.permission_layers ?? [])] + result.permission = [perms, ...ConfigPermission.toLayers(result.permission)] } if (!result.username) result.username = os.userInfo().username diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index a04b404e86..e9853f47a3 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -57,3 +57,11 @@ export const Info = InputSchema.pipe( ).annotate({ identifier: "PermissionConfig" }) type _Info = Schema.Schema.Type export type Info = { -readonly [K in keyof _Info]: _Info[K] } + +// Top-level config accepts either a single permission object or an array of +// layered configs. Internal merging produces arrays; this helper normalises +// either shape into the array form expected by consumers. +export function toLayers(value: Info | Info[] | undefined): Info[] { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 454f3e487a..a2e4391777 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -278,6 +278,40 @@ test("updates global config and omits empty shell key in json", async () => { } }) +test("global config update preserves single-object permission shape on disk", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + shell: "bash", + permission: { bash: "ask" }, + }), + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear(true) + + try { + // Updating an unrelated key must not rewrite `permission` from object to array form. + await saveGlobal({ shell: "zsh" }) + + const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>( + path.join(tmp.path, "opencode.json"), + ) + expect(written.shell).toBe("zsh") + expect(Array.isArray(written.permission)).toBe(false) + expect(written.permission).toEqual({ bash: "ask" }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + test("updates global config and omits empty shell key in jsonc", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -1715,7 +1749,10 @@ test("permission config preserves user key order", async () => { directory: tmp.path, fn: async () => { const config = await load() - expect(Object.keys(config.permission!)).toEqual([ + // load() goes through the merge pipeline, producing the layered array form + expect(config.permission).toHaveLength(1) + const perm = (config.permission as ConfigPermission.Info[])[0] + expect(Object.keys(perm)).toEqual([ "*", "edit", "write", @@ -1758,12 +1795,11 @@ test("user top-level catchall overrides inherited bash rules", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() - // Use permission_layers for correct ordering (each layer's rules come after previous) - const layers = (config.permission_layers ?? []) as ConfigPermission.Info[] + const layers = ConfigPermission.toLayers(config.permission) const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") @@ -1799,12 +1835,11 @@ test("inherited bash rules apply when no user top-level catchall", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() - // Use permission_layers for correct ordering - const layers = (config.permission_layers ?? []) as ConfigPermission.Info[] + const layers = ConfigPermission.toLayers(config.permission) const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny") @@ -1839,12 +1874,11 @@ test("user bash catchall overrides inherited bash rules", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() - // Use permission_layers for correct ordering - const layers = (config.permission_layers ?? []) as ConfigPermission.Info[] + const layers = ConfigPermission.toLayers(config.permission) const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") @@ -1870,7 +1904,8 @@ test("config parser preserves permission order while rejecting unknown top-level "test", ) - expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"]) + // ConfigParse.schema preserves the raw shape the user wrote + expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"]) try { ConfigParse.schema(Config.Info, { invalid_field: true }, "test") throw new Error("expected config parse to fail") @@ -2707,11 +2742,12 @@ test("parseManagedPlist parses permission rules", async () => { ), "test:mobileconfig", ) - expect(config.permission?.["*"]).toBe("ask") - expect(config.permission?.grep).toBe("allow") - expect(config.permission?.webfetch).toBe("ask") - expect(config.permission?.["~/.ssh/*"]).toBe("deny") - const bash = config.permission?.bash as Record + const perm = config.permission as ConfigPermission.Info + expect(perm?.["*"]).toBe("ask") + expect(perm?.grep).toBe("allow") + expect(perm?.webfetch).toBe("ask") + expect(perm?.["~/.ssh/*"]).toBe("deny") + const bash = perm?.bash as Record expect(bash?.["rm -rf *"]).toBe("deny") expect(bash?.["curl *"]).toBe("deny") }) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 64b93bb8bc..77ceda8a4c 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { Instance } from "../src/project/instance" import { WithInstance } from "../src/project/with-instance" import { disposeAllInstances, tmpdir } from "./fixture/fixture" @@ -163,7 +164,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // general and orchestrator-fast should be allowed, code-reviewer denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") @@ -188,7 +191,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // general and code-reviewer should be ask, orchestrator-* denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") @@ -213,7 +218,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") // Unspecified agents default to "ask" @@ -240,7 +247,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Verify task permissions expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") @@ -278,7 +287,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Last matching rule wins - "*" deny is last, so all agents are denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny") @@ -309,7 +320,9 @@ describe("permission.task with real config files", () => { directory: tmp.path, fn: async () => { const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) + const ruleset = Permission.merge( + ...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)), + ) // Evaluate uses findLast - "general" allow comes after "*" deny expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") From a301c5ceb02fda01c920375028f0ea95d4d7e70f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 12 May 2026 18:23:52 -0500 Subject: [PATCH 3/4] more fixes for perms --- packages/opencode/src/config/config.ts | 45 +++++++------- packages/opencode/src/config/permission.ts | 12 +++- packages/opencode/test/config/config.test.ts | 62 ++++++++++++++++++++ 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index dec1397a75..6c7310f75d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -45,21 +45,16 @@ import { Npm } from "@opencode-ai/core/npm" const log = Log.create({ service: "config" }) -// Custom merge function that concatenates array fields instead of replacing them -// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. -function mergeConfig(target: Info, source: Info): Info { - return mergeDeep(target, source) as Info -} - -function mergeConfigConcatArrays(target: Info, source: Info): Info { - const merged = mergeConfig(target, source) +// Custom merge: deep-merges most fields, but dedupes `instructions` and +// concatenates `permission` as layered configs so user-written rule ordering +// is preserved across config sources. +// Keep remeda's deep conditional merge type out of hot config-loading paths; +// TS profiling showed it dominates here. +function mergeConfigs(target: Info, source: Info): Info { + const merged = mergeDeep(target, source) as Info if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } - // Accumulate permission layers for later merging as rulesets. - // This preserves the ordering semantics: later rules override earlier rules. - // Each layer keeps the raw shape the user wrote on disk; consumers should use - // ConfigPermission.toLayers to normalise. if (source.permission) { merged.permission = [...ConfigPermission.toLayers(target.permission), ...ConfigPermission.toLayers(source.permission)] } @@ -236,9 +231,7 @@ export const Info = Schema.Struct({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional( - Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]), - ).annotate({ + permission: Schema.optional(ConfigPermission.LayersInput).annotate({ description: "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.", }), @@ -431,9 +424,9 @@ export const layer = Layer.effect( .pipe(Effect.catch(() => Effect.void)) } } - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) + result = mergeConfigs(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) + result = mergeConfigs(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) + result = mergeConfigs(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) const legacy = path.join(Global.Path.config, "config") if (existsSync(legacy)) { @@ -443,7 +436,7 @@ export const layer = Layer.effect( const { provider, model, ...rest } = mod.default if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://opencode.ai/config.json" - result = mergeConfig(result, rest) + result = mergeConfigs(result, rest) await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fsNode.unlink(legacy) }) @@ -523,7 +516,7 @@ export const layer = Layer.effect( }) const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { - result = mergeConfigConcatArrays(result, next) + result = mergeConfigs(result, next) return mergePluginOrigins(source, next.plugin, kind) } @@ -557,7 +550,7 @@ export const layer = Layer.effect( return isRecord(data) && isRecord(data.config) ? data.config : data })) as Record) : {} - const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info) + const remoteConfig = mergeConfigs(wellknown.config ?? {}, fetchedConfig as Info) if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" const source = `${url}/.well-known/opencode` const next = yield* loadConfig(JSON.stringify(remoteConfig), { @@ -701,7 +694,7 @@ export const layer = Layer.effect( // macOS managed preferences (.mobileconfig deployed via MDM) override everything const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) if (managed) { - result = mergeConfigConcatArrays( + result = mergeConfigs( result, yield* loadConfig(managed.text, { dir: path.dirname(managed.source), @@ -720,8 +713,12 @@ export const layer = Layer.effect( } if (Flag.OPENCODE_PERMISSION) { - const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info - result.permission = [...ConfigPermission.toLayers(result.permission), envPermission] + const envPermission = ConfigParse.schema( + ConfigPermission.LayersInput, + JSON.parse(Flag.OPENCODE_PERMISSION), + "OPENCODE_PERMISSION", + ) + result.permission = [...ConfigPermission.toLayers(result.permission), ...ConfigPermission.toLayers(envPermission)] } if (result.tools) { diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index e9853f47a3..a75e71170a 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -59,9 +59,15 @@ type _Info = Schema.Schema.Type export type Info = { -readonly [K in keyof _Info]: _Info[K] } // Top-level config accepts either a single permission object or an array of -// layered configs. Internal merging produces arrays; this helper normalises -// either shape into the array form expected by consumers. -export function toLayers(value: Info | Info[] | undefined): Info[] { +// layered configs. Validated input goes through this union; runtime merging +// always produces arrays. +export const LayersInput = Schema.Union([Info, Schema.mutable(Schema.Array(Info))]).annotate({ + identifier: "PermissionLayersInput", +}) +export type LayersInput = Schema.Schema.Type + +// Normalise either shape into the array form expected by consumers. +export function toLayers(value: LayersInput | undefined): Info[] { if (!value) return [] return Array.isArray(value) ? value : [value] } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index a2e4391777..2bbcedc772 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1891,6 +1891,68 @@ test("user bash catchall overrides inherited bash rules", async () => { }) }) +// Permissions split across multiple global config files (config.json + opencode.json) +// must layer in load order rather than deep-merging into a single object. +test("multiple global config files preserve permission layer ordering", async () => { + await using globalTmp = await tmpdir() + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear(true) + + try { + // First global file: deny rm-style commands. + await writeConfig(globalTmp.path, { + $schema: "https://opencode.ai/config.json", + permission: { bash: { "rm *": "deny" } }, + }, "config.json") + // Second global file: top-level catchall "ask" — must come *after* the deny layer. + await writeConfig(globalTmp.path, { + $schema: "https://opencode.ai/config.json", + permission: { "*": "ask" }, + }, "opencode.json") + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + const layers = ConfigPermission.toLayers(config.permission) + // Each global file contributes its own layer. + expect(layers.length).toBeGreaterThanOrEqual(2) + const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) + // Later "*": "ask" overrides earlier "rm *": "deny" — ordering is preserved. + expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask") + }, + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + +test("OPENCODE_PERMISSION env var rejects malformed input", () => { + // Validates the env-var parser surfaces clear errors instead of silently casting. + expect(() => + ConfigParse.schema( + ConfigPermission.LayersInput, + { bash: "maybe" }, + "OPENCODE_PERMISSION", + ), + ).toThrow() +}) + +test("OPENCODE_PERMISSION env var accepts both single-object and array syntax", () => { + const single = ConfigParse.schema(ConfigPermission.LayersInput, { bash: "deny" }, "OPENCODE_PERMISSION") + expect(ConfigPermission.toLayers(single)).toHaveLength(1) + + const layered = ConfigParse.schema( + ConfigPermission.LayersInput, + [{ bash: "deny" }, { bash: { "echo *": "allow" } }], + "OPENCODE_PERMISSION", + ) + expect(ConfigPermission.toLayers(layered)).toHaveLength(2) +}) + test("config parser preserves permission order while rejecting unknown top-level keys", () => { const config = ConfigParse.schema( Config.Info, From bc9fc5aa33b857bd356fa88817d106b45c4379fb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 12 May 2026 20:05:32 -0500 Subject: [PATCH 4/4] restore --- .opencode/opencode.jsonc | 48 +++++++++------------------------------- 1 file changed, 10 insertions(+), 38 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index b4893b67a2..0ae2fbe26b 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,38 +1,10 @@ -// { -// "$schema": "https://opencode.ai/config.json", -// "provider": {}, -// // Array form: each entry is its own permission layer; later layers override earlier rules. -// "permission": [ -// // Layer 1: safety defaults. -// { -// "bash": { -// "rm -rf *": "deny", -// }, -// }, -// // Layer 2: project-specific rules — uses per-field shorthand and object forms. -// { -// "edit": { -// "packages/opencode/migration/*": "ask", -// }, -// "webfetch": "allow", -// }, -// // Layer 3: deny everything that runs through bash. Shorthand for bash: { "*": "deny" }. -// { -// "bash": "deny", -// }, -// // Layer 4: relax layer 3 for a specific pattern. Later layer wins for matching commands, -// // so `echo foo` is allowed while everything else stays denied. -// { -// "bash": { -// "echo *": "allow", -// }, -// }, -// ], -// "mcp": {}, -// "tools": { -// "github-triage": false, -// "github-pr-search": false, -// }, -// } -{} - +{ + "$schema": "https://opencode.ai/config.json", + "provider": {}, + "permission": {}, + "mcp": {}, + "tools": { + "github-triage": false, + "github-pr-search": false, + }, +}