Compare commits

...

9 Commits

Author SHA1 Message Date
Aiden Cline 4437eb0bc0 Merge branch 'dev' into adjust-perm-array-logic
# Conflicts:
#	packages/opencode/src/config/config.ts
#	packages/opencode/src/config/permission.ts
#	packages/opencode/test/config/config.test.ts
2026-05-13 11:19:04 -05:00
Aiden Cline 9de91b0f1f Merge branch 'dev' into adjust-perm-array-logic 2026-05-12 22:21:41 -05:00
Aiden Cline 6fbcad4020 Merge branch 'dev' into adjust-perm-array-logic 2026-05-12 20:25:55 -05:00
Aiden Cline bc9fc5aa33 restore 2026-05-12 20:05:32 -05:00
Aiden Cline b8c14b5bad Merge branch 'dev' into adjust-perm-array-logic 2026-05-12 19:58:59 -05:00
Aiden Cline a301c5ceb0 more fixes for perms 2026-05-12 18:23:52 -05:00
Aiden Cline d4e3106fca 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.
2026-05-12 01:30:10 -05:00
Aiden Cline d0c602bcab Merge branch 'dev' into fix-permissions 2026-05-12 00:02:33 -05:00
Andrew Suffield 809e1e5347 fix: merging permissions objects will discard ordering. instead, preserve permissions objects as layers, convert them individually to rulesets, and merge the rulesets (#16157) 2026-04-21 13:01:41 -04:00
5 changed files with 257 additions and 32 deletions
+2 -1
View File
@@ -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<string, Info> = {
build: {
+34 -18
View File
@@ -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<string, unknown>)
: {}
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
@@ -56,3 +56,17 @@ export const Info = InputSchema.pipe(
).annotate({ identifier: "PermissionConfig" })
type _Info = Schema.Schema.Type<typeof InputObject>
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<typeof LayersInput>
// 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]
}
+200 -7
View File
@@ -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<string, string>
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<string, string>
expect(bash?.["rm -rf *"]).toBe("deny")
expect(bash?.["curl *"]).toBe("deny")
})
@@ -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")