diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 7cfbaec01f..eb3ac412bd 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,16 +1,8 @@ export * as ConfigPermission from "./permission" import { Schema } from "effect" -import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod, ZodPreprocess } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -const permissionPreprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __originalKeys: globalThis.Object.keys(val), ...val } - } - return val -} - export const Action = Schema.Literals(["ask", "allow", "deny"]) .annotate({ identifier: "PermissionActionConfig" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -26,6 +18,48 @@ export const Rule = Schema.Union([Action, Object]) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Rule = Schema.Schema.Type +// Captures the user's original property insertion order before Schema.Struct +// canonicalises the object. See the `ZodPreprocess` comment in +// `util/effect-zod.ts` for the full rationale — in short: rule precedence is +// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win) +// and `Schema.StructWithRest` would otherwise drop that order. +const permissionPreprocess = (val: unknown) => { + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + return { __originalKeys: globalThis.Object.keys(val), ...val } + } + return val +} + +const ObjectShape = Schema.StructWithRest( + Schema.Struct({ + __originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + read: Schema.optional(Rule), + edit: Schema.optional(Rule), + glob: Schema.optional(Rule), + grep: Schema.optional(Rule), + list: Schema.optional(Rule), + bash: Schema.optional(Rule), + task: Schema.optional(Rule), + external_directory: Schema.optional(Rule), + todowrite: Schema.optional(Action), + question: Schema.optional(Action), + webfetch: Schema.optional(Action), + websearch: Schema.optional(Action), + codesearch: Schema.optional(Action), + lsp: Schema.optional(Rule), + doom_loop: Schema.optional(Action), + skill: Schema.optional(Rule), + }), + [Schema.Record(Schema.String, Rule)], +) + +const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({ + [ZodPreprocess]: permissionPreprocess, +}) + +// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the +// user's original insertion order. A plain string input (the Action branch of +// the union) becomes `{ "*": action }`. const transform = (x: unknown): Record => { if (typeof x === "string") return { "*": x as Action } const obj = x as { __originalKeys?: string[] } & Record @@ -38,34 +72,7 @@ const transform = (x: unknown): Record => { return result } -export const Info = z - .preprocess( - permissionPreprocess, - z - .object({ - __originalKeys: z.string().array().optional(), - read: Rule.zod.optional(), - edit: Rule.zod.optional(), - glob: Rule.zod.optional(), - grep: Rule.zod.optional(), - list: Rule.zod.optional(), - bash: Rule.zod.optional(), - task: Rule.zod.optional(), - external_directory: Rule.zod.optional(), - todowrite: Action.zod.optional(), - question: Action.zod.optional(), - webfetch: Action.zod.optional(), - websearch: Action.zod.optional(), - codesearch: Action.zod.optional(), - lsp: Rule.zod.optional(), - doom_loop: Action.zod.optional(), - skill: Rule.zod.optional(), - }) - .catchall(Rule.zod) - .or(Action.zod), - ) +export const Info = zod(InnerSchema) .transform(transform) - .meta({ - ref: "PermissionConfig", - }) -export type Info = z.infer + .meta({ ref: "PermissionConfig" }) +export type Info = Record