fix: preserve permission ordering by accepting a layered array (#23214)

Co-authored-by: Andrew Suffield <asuffield@cloudflare.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
Andrew Suffield
2026-05-12 18:09:06 -04:00
committed by GitHub
parent 2cb697b720
commit 65368f609d
5 changed files with 221 additions and 18 deletions

View File

@@ -55,6 +55,13 @@ 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.
// 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)]
}
return merged
}
@@ -228,7 +235,12 @@ 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(
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({
description: "Attachment processing configuration, including image size limits and resizing behavior",
@@ -704,11 +716,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 = [...ConfigPermission.toLayers(result.permission), envPermission]
}
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
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") {
@@ -717,7 +730,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 = [perms, ...ConfigPermission.toLayers(result.permission)]
}
if (!result.username) result.username = os.userInfo().username

View File

@@ -56,3 +56,11 @@ 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. 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]
}