Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4437eb0bc0 | |||
| 9de91b0f1f | |||
| 6fbcad4020 | |||
| bc9fc5aa33 | |||
| b8c14b5bad | |||
| a301c5ceb0 | |||
| d4e3106fca | |||
| d0c602bcab | |||
| 809e1e5347 |
@@ -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: {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user