diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 1e4d7e1563..fc2360e101 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,4 +1,5 @@ import { Config } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" @@ -118,7 +119,7 @@ export const layer = Layer.effect( }, }) - const user = Permission.fromConfig(cfg.permission ?? {}) + const user = Permission.merge(...ConfigPermission.toLayers(cfg.permission).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 545e48e64d..c52f66678d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -44,17 +44,22 @@ 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])) } + if (source.permission) { + merged.permission = [ + ...ConfigPermission.toLayers(target.permission), + ...ConfigPermission.toLayers(source.permission), + ] + } return merged } @@ -228,7 +233,10 @@ 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: 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.", + }), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), attachment: Schema.optional(ConfigAttachment.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", @@ -415,9 +423,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)) { @@ -427,7 +435,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) }) @@ -507,7 +515,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) } @@ -541,7 +549,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), { @@ -685,7 +693,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), @@ -704,7 +712,15 @@ export const layer = Layer.effect( } if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + 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) { @@ -717,7 +733,7 @@ export const layer = Layer.effect( } perms[tool] = action } - result.permission = mergeDeep(perms, result.permission ?? {}) + 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 1092ae2b7e..805e24a33c 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -56,3 +56,17 @@ 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. 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 90e78efcdb..4124756f4f 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2,8 +2,10 @@ 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 } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" +import { Permission } from "@/permission" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" @@ -1713,7 +1715,8 @@ test("permission config preserves user key order", async () => { directory: tmp.path, fn: async () => { const config = await load() - expect(Object.keys(config.permission!)).toEqual([ + const [permission] = ConfigPermission.toLayers(config.permission) + expect(Object.keys(permission)).toEqual([ "*", "edit", "write", @@ -1729,6 +1732,195 @@ test("permission config preserves user 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 WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + 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") + 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 WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + 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") + 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 WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + 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") + 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") + }, + }) +}) + +// 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, @@ -1742,7 +1934,7 @@ test("config parser preserves permission order while rejecting unknown top-level "test", ) - expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"]) + 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") @@ -2579,11 +2771,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 permission = config.permission as ConfigPermission.Info + expect(permission?.["*"]).toBe("ask") + expect(permission?.grep).toBe("allow") + expect(permission?.webfetch).toBe("ask") + expect(permission?.["~/.ssh/*"]).toBe("deny") + const bash = permission?.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 f2084b095d..d16ec8fb5c 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test" import { Effect } from "effect" import { Permission } from "../src/permission" import { Config } from "@/config/config" +import { ConfigPermission } from "@/config/permission" import { testEffect } from "./lib/effect" const it = testEffect(Config.defaultLayer) @@ -148,7 +149,7 @@ describe("permission.task with real config files", () => { () => Effect.gen(function* () { const config = yield* 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") @@ -172,7 +173,7 @@ describe("permission.task with real config files", () => { () => Effect.gen(function* () { const config = yield* 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") @@ -196,7 +197,7 @@ describe("permission.task with real config files", () => { () => Effect.gen(function* () { const config = yield* 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" @@ -220,7 +221,7 @@ describe("permission.task with real config files", () => { () => Effect.gen(function* () { const config = yield* 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") @@ -258,7 +259,7 @@ describe("permission.task with real config files", () => { () => Effect.gen(function* () { const config = yield* 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") @@ -289,7 +290,7 @@ describe("permission.task with real config files", () => { () => Effect.gen(function* () { const config = yield* 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")