more fixes for perms
This commit is contained in:
parent
d4e3106fca
commit
a301c5ceb0
3 changed files with 92 additions and 27 deletions
|
|
@ -45,21 +45,16 @@ import { Npm } from "@opencode-ai/core/npm"
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
// Custom merge function that concatenates array fields instead of replacing them
|
// Custom merge: deep-merges most fields, but dedupes `instructions` and
|
||||||
// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.
|
// concatenates `permission` as layered configs so user-written rule ordering
|
||||||
function mergeConfig(target: Info, source: Info): Info {
|
// is preserved across config sources.
|
||||||
return mergeDeep(target, source) as Info
|
// 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 {
|
||||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
const merged = mergeDeep(target, source) as Info
|
||||||
const merged = mergeConfig(target, source)
|
|
||||||
if (target.instructions && source.instructions) {
|
if (target.instructions && source.instructions) {
|
||||||
merged.instructions = Array.from(new Set([...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) {
|
if (source.permission) {
|
||||||
merged.permission = [...ConfigPermission.toLayers(target.permission), ...ConfigPermission.toLayers(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",
|
description: "Additional instruction files or patterns to include",
|
||||||
}),
|
}),
|
||||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||||
permission: Schema.optional(
|
permission: Schema.optional(ConfigPermission.LayersInput).annotate({
|
||||||
Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]),
|
|
||||||
).annotate({
|
|
||||||
description:
|
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.",
|
"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))
|
.pipe(Effect.catch(() => Effect.void))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
result = mergeConfigs(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
||||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
result = mergeConfigs(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, "opencode.jsonc")))
|
||||||
|
|
||||||
const legacy = path.join(Global.Path.config, "config")
|
const legacy = path.join(Global.Path.config, "config")
|
||||||
if (existsSync(legacy)) {
|
if (existsSync(legacy)) {
|
||||||
|
|
@ -443,7 +436,7 @@ export const layer = Layer.effect(
|
||||||
const { provider, model, ...rest } = mod.default
|
const { provider, model, ...rest } = mod.default
|
||||||
if (provider && model) result.model = `${provider}/${model}`
|
if (provider && model) result.model = `${provider}/${model}`
|
||||||
result["$schema"] = "https://opencode.ai/config.json"
|
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.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||||
await fsNode.unlink(legacy)
|
await fsNode.unlink(legacy)
|
||||||
})
|
})
|
||||||
|
|
@ -523,7 +516,7 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
|
|
||||||
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
|
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
|
||||||
result = mergeConfigConcatArrays(result, next)
|
result = mergeConfigs(result, next)
|
||||||
return mergePluginOrigins(source, next.plugin, kind)
|
return mergePluginOrigins(source, next.plugin, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,7 +550,7 @@ export const layer = Layer.effect(
|
||||||
return isRecord(data) && isRecord(data.config) ? data.config : data
|
return isRecord(data) && isRecord(data.config) ? data.config : data
|
||||||
})) as Record<string, unknown>)
|
})) 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"
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||||
const source = `${url}/.well-known/opencode`
|
const source = `${url}/.well-known/opencode`
|
||||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
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
|
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
||||||
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
|
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
|
||||||
if (managed) {
|
if (managed) {
|
||||||
result = mergeConfigConcatArrays(
|
result = mergeConfigs(
|
||||||
result,
|
result,
|
||||||
yield* loadConfig(managed.text, {
|
yield* loadConfig(managed.text, {
|
||||||
dir: path.dirname(managed.source),
|
dir: path.dirname(managed.source),
|
||||||
|
|
@ -720,8 +713,12 @@ export const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Flag.OPENCODE_PERMISSION) {
|
if (Flag.OPENCODE_PERMISSION) {
|
||||||
const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info
|
const envPermission = ConfigParse.schema(
|
||||||
result.permission = [...ConfigPermission.toLayers(result.permission), envPermission]
|
ConfigPermission.LayersInput,
|
||||||
|
JSON.parse(Flag.OPENCODE_PERMISSION),
|
||||||
|
"OPENCODE_PERMISSION",
|
||||||
|
)
|
||||||
|
result.permission = [...ConfigPermission.toLayers(result.permission), ...ConfigPermission.toLayers(envPermission)]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.tools) {
|
if (result.tools) {
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,15 @@ type _Info = Schema.Schema.Type<typeof InputObject>
|
||||||
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
|
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
|
||||||
|
|
||||||
// Top-level config accepts either a single permission object or an array of
|
// Top-level config accepts either a single permission object or an array of
|
||||||
// layered configs. Internal merging produces arrays; this helper normalises
|
// layered configs. Validated input goes through this union; runtime merging
|
||||||
// either shape into the array form expected by consumers.
|
// always produces arrays.
|
||||||
export function toLayers(value: Info | Info[] | undefined): Info[] {
|
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 []
|
if (!value) return []
|
||||||
return Array.isArray(value) ? value : [value]
|
return Array.isArray(value) ? value : [value]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||||
const config = ConfigParse.schema(
|
const config = ConfigParse.schema(
|
||||||
Config.Info,
|
Config.Info,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue