Skip to content

Commit b0f565b

Browse files
authored
refactor(core): migrate ConfigPermission.Info to Effect Schema canonical (anomalyco#23740)
1 parent 2ae64f4 commit b0f565b

9 files changed

Lines changed: 132 additions & 208 deletions

File tree

packages/opencode/src/config/agent.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,6 @@ const Color = Schema.Union([
2222
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
2323
])
2424

25-
// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
26-
// shape lives outside the Effect Schema type system), so the walker reaches it
27-
// via ZodOverride rather than a pure Schema reference. This preserves the
28-
// `$ref: PermissionConfig` emitted in openapi.json.
29-
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
30-
3125
const AgentSchema = Schema.StructWithRest(
3226
Schema.Struct({
3327
model: Schema.optional(ConfigModelID),
@@ -54,7 +48,7 @@ const AgentSchema = Schema.StructWithRest(
5448
description: "Maximum number of agentic iterations before forcing text-only response",
5549
}),
5650
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
57-
permission: Schema.optional(PermissionRef),
51+
permission: Schema.optional(ConfigPermission.Info),
5852
}),
5953
[Schema.Record(Schema.String, Schema.Any)],
6054
)

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ export type Layout = ConfigLayout.Layout
8686
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
8787
// exact zod directly, preserving component $refs.
8888
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
89-
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
9089
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
9190

9291
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
@@ -198,7 +197,7 @@ export const Info = Schema.Struct({
198197
description: "Additional instruction files or patterns to include",
199198
}),
200199
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
201-
permission: Schema.optional(PermissionRef),
200+
permission: Schema.optional(ConfigPermission.Info),
202201
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
203202
enterprise: Schema.optional(
204203
Schema.Struct({
Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * as ConfigPermission from "./permission"
2-
import { Schema } from "effect"
3-
import { zod, ZodPreprocess } from "@/util/effect-zod"
2+
import { Schema, SchemaGetter } from "effect"
3+
import { zod } from "@/util/effect-zod"
44
import { withStatics } from "@/util/schema"
55

66
export const Action = Schema.Literals(["ask", "allow", "deny"])
@@ -18,21 +18,19 @@ export const Rule = Schema.Union([Action, Object])
1818
.pipe(withStatics((s) => ({ zod: zod(s) })))
1919
export type Rule = Schema.Schema.Type<typeof Rule>
2020

21-
// Captures the user's original property insertion order before Schema.Struct
22-
// canonicalises the object. See the `ZodPreprocess` comment in
23-
// `util/effect-zod.ts` for the full rationale — in short: rule precedence is
24-
// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win)
25-
// and `Schema.StructWithRest` would otherwise drop that order.
26-
const permissionPreprocess = (val: unknown) => {
27-
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
28-
return { __originalKeys: globalThis.Object.keys(val), ...val }
29-
}
30-
return val
31-
}
32-
33-
const ObjectShape = Schema.StructWithRest(
21+
// Known permission keys get explicit types — most are full Rule (either a
22+
// single Action or a per-pattern object), but a handful of tools take no
23+
// sub-target patterns and are Action-only. Unknown keys fall through the
24+
// Record rest signature as Rule.
25+
//
26+
// StructWithRest canonicalises key order on decode (known first, then rest),
27+
// which used to require the `__originalKeys` preprocess hack because
28+
// `Permission.fromConfig` depended on the user's insertion order. That
29+
// dependency is gone — `fromConfig` now sorts top-level keys so wildcard
30+
// permissions come before specifics, making the final precedence
31+
// order-independent.
32+
const InputObject = Schema.StructWithRest(
3433
Schema.Struct({
35-
__originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
3634
read: Schema.optional(Rule),
3735
edit: Schema.optional(Rule),
3836
glob: Schema.optional(Rule),
@@ -53,24 +51,29 @@ const ObjectShape = Schema.StructWithRest(
5351
[Schema.Record(Schema.String, Rule)],
5452
)
5553

56-
const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
57-
[ZodPreprocess]: permissionPreprocess,
58-
})
54+
// Input the user writes in config: either a single Action (shorthand for "*")
55+
// or an object of per-target rules.
56+
const InputSchema = Schema.Union([Action, InputObject])
5957

60-
// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the
61-
// user's original insertion order. A plain string input (the Action branch of
62-
// the union) becomes `{ "*": action }`.
63-
const transform = (x: unknown): Record<string, Rule> => {
64-
if (typeof x === "string") return { "*": x as Action }
65-
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
66-
const { __originalKeys, ...rest } = obj
67-
if (!__originalKeys) return rest as Record<string, Rule>
68-
const result: Record<string, Rule> = {}
69-
for (const key of __originalKeys) {
70-
if (key in rest) result[key] = rest[key] as Rule
71-
}
72-
return result
73-
}
58+
// Normalise the Action shorthand into `{ "*": action }`. Object inputs pass
59+
// through untouched.
60+
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
61+
typeof input === "string" ? { "*": input } : input
7462

75-
export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
76-
export type Info = Record<string, Rule>
63+
export const Info = InputSchema.pipe(
64+
Schema.decodeTo(InputObject, {
65+
decode: SchemaGetter.transform(normalizeInput),
66+
// Not perfectly invertible (we lose whether the user originally typed an
67+
// Action shorthand), but the object form is always a valid representation
68+
// of the same rules.
69+
encode: SchemaGetter.passthrough({ strict: false }),
70+
}),
71+
)
72+
.annotate({ identifier: "PermissionConfig" })
73+
.pipe(
74+
// Walker already emits the decodeTo transform into the derived zod (see
75+
// `encoded()` in effect-zod.ts), so just expose that directly.
76+
withStatics((s) => ({ zod: zod(s) })),
77+
)
78+
type _Info = Schema.Schema.Type<typeof InputObject>
79+
export type Info = { -readonly [K in keyof _Info]: _Info[K] }

packages/opencode/src/permission/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,18 @@ function expand(pattern: string): string {
290290
}
291291

292292
export function fromConfig(permission: ConfigPermission.Info) {
293+
// Sort top-level keys so wildcard permissions (`*`, `mcp_*`) come before
294+
// specific ones. Combined with `findLast` in evaluate(), this gives the
295+
// intuitive semantic "specific tool rules override the `*` fallback"
296+
// regardless of the user's JSON key order. Sub-pattern order inside a
297+
// single permission key is preserved — only top-level keys are sorted.
298+
const entries = Object.entries(permission).sort(([a], [b]) => {
299+
const aWild = a.includes("*")
300+
const bWild = b.includes("*")
301+
return aWild === bWild ? 0 : aWild ? -1 : 1
302+
})
293303
const ruleset: Ruleset = []
294-
for (const [key, value] of Object.entries(permission)) {
304+
for (const [key, value] of entries) {
295305
if (typeof value === "string") {
296306
ruleset.push({ permission: key, action: value, pattern: "*" })
297307
continue

packages/opencode/src/util/effect-zod.ts

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,6 @@ import z from "zod"
88
*/
99
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
1010

11-
/**
12-
* Annotation key for a pre-parse transform that runs on the raw input before
13-
* the derived Zod schema validates it. The walker emits
14-
* `z.preprocess(fn, inner)` when this annotation is present.
15-
*
16-
* Models zod's `z.preprocess(fn, schema)` pattern — useful when the schema
17-
* needs to inspect the user's raw input (e.g. to capture insertion order)
18-
* before `Schema.Struct` canonicalises the object.
19-
*
20-
* TODO: This exists to paper over a missing Effect Schema feature. The
21-
* parser canonicalises open struct output (known fields first in
22-
* declaration order, then catchall fields) before any user-defined
23-
* transform sees the value, and there is no pre-parse hook — so the
24-
* user's original property insertion order is gone by the time
25-
* `Schema.decodeTo` or `middlewareDecoding` runs.
26-
*
27-
* That canonicalisation is a reasonable default, but `config/permission.ts`
28-
* encodes rule precedence in the user's JSON key order (`evaluate.ts`
29-
* uses `findLast`, so later entries win), which the canonicalisation
30-
* silently destroys.
31-
*
32-
* The cleanest upstream fix would be either:
33-
*
34-
* 1. A `preserveInputOrder` option on `Schema.Struct` /
35-
* `Schema.StructWithRest` that keeps the input's insertion order in
36-
* the parsed object (opt-in; canonical order stays default).
37-
* 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a
38-
* transformation whose decode receives the raw `unknown`).
39-
*
40-
* Either of those would let us delete `ZodPreprocess` and the
41-
* `__originalKeys` hack. Alternatively, the permission model could move
42-
* to specificity-based precedence (exact keys beat wildcards) or an
43-
* explicit ordered array of rules, which removes the ordering
44-
* dependency at the data-model level.
45-
*/
46-
export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess")
47-
4811
// AST nodes are immutable and frequently shared across schemas (e.g. a single
4912
// Schema.Class embedded in multiple parents). Memoizing by node identity
5013
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
@@ -85,11 +48,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
8548
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
8649
const base = hasTransform ? encoded(ast) : body(ast)
8750
const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
88-
const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess]
89-
const out = preprocess ? z.preprocess(preprocess, checked) : checked
9051
const desc = SchemaAST.resolveDescription(ast)
9152
const ref = SchemaAST.resolveIdentifier(ast)
92-
const described = desc ? out.describe(desc) : out
53+
const described = desc ? checked.describe(desc) : checked
9354
return ref ? described.meta({ ref }) : described
9455
}
9556

packages/opencode/test/config/config.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,7 +1495,16 @@ test("merges legacy tools with existing permission config", async () => {
14951495
})
14961496
})
14971497

1498-
test("permission config preserves key order", async () => {
1498+
test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => {
1499+
// ConfigPermission.Info is a StructWithRest schema — the decoder reorders
1500+
// keys into declaration-order for known permission names (edit, read,
1501+
// todowrite, external_directory are declared in `config/permission.ts`),
1502+
// followed by rest keys in the user's insertion order.
1503+
//
1504+
// Rule precedence is NOT affected by this reordering: `Permission.fromConfig`
1505+
// sorts wildcards before specifics before iterating. See the
1506+
// "fromConfig - specific key beats wildcard regardless of JSON key order"
1507+
// test in test/permission/next.test.ts for the behavioural guarantee.
14991508
await using tmp = await tmpdir({
15001509
init: async (dir) => {
15011510
await Filesystem.write(
@@ -1523,12 +1532,15 @@ test("permission config preserves key order", async () => {
15231532
fn: async () => {
15241533
const config = await load()
15251534
expect(Object.keys(config.permission!)).toEqual([
1526-
"*",
1535+
// known fields that the user provided, in declaration order from
1536+
// config/permission.ts (read, edit, ..., external_directory, todowrite)
1537+
"read",
15271538
"edit",
1528-
"write",
15291539
"external_directory",
1530-
"read",
15311540
"todowrite",
1541+
// rest keys (not in the known list), in user's insertion order
1542+
"*",
1543+
"write",
15321544
"thoughts_*",
15331545
"reasoning_model_*",
15341546
"tools_*",

packages/opencode/test/permission/next.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,67 @@ test("fromConfig - does not expand tilde in middle of path", () => {
128128
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
129129
})
130130

131+
// Top-level wildcard-vs-specific precedence semantics.
132+
//
133+
// fromConfig sorts top-level keys so wildcard permissions (containing "*")
134+
// come before specific permissions. Combined with `findLast` in evaluate(),
135+
// this gives the intuitive semantic "specific tool rules override the `*`
136+
// fallback", regardless of the order the user wrote the keys in their JSON.
137+
//
138+
// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`)
139+
// still depends on insertion order — only top-level keys are sorted.
140+
141+
test("fromConfig - specific key beats wildcard regardless of JSON key order", () => {
142+
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
143+
const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
144+
145+
// Both orderings produce the same ruleset
146+
expect(wildcardFirst).toEqual(specificFirst)
147+
148+
// And both evaluate bash → allow (bash rule wins over * fallback)
149+
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
150+
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
151+
})
152+
153+
test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => {
154+
const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" })
155+
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
156+
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
157+
})
158+
159+
test("fromConfig - top-level ordering: wildcards first, specifics after", () => {
160+
const ruleset = Permission.fromConfig({
161+
bash: "allow",
162+
"*": "ask",
163+
edit: "deny",
164+
"mcp_*": "allow",
165+
})
166+
// wildcards (* and mcp_*) come before specifics (bash, edit)
167+
const permissions = ruleset.map((r) => r.permission)
168+
expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"])
169+
expect(permissions.slice(2)).toEqual(["bash", "edit"])
170+
})
171+
172+
test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => {
173+
// Sub-patterns within a single tool key use the documented "`*` first,
174+
// specific patterns after" convention (findLast picks specifics). The
175+
// top-level sort must not touch sub-pattern ordering.
176+
const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
177+
expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
178+
// * fallback for unknown commands
179+
expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
180+
// specific pattern wins for git commands (it's last, findLast picks it)
181+
expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
182+
})
183+
184+
test("fromConfig - canonical documented example unchanged", () => {
185+
// Regression guard for the example in docs/permissions.mdx
186+
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
187+
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
188+
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
189+
expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask")
190+
})
191+
131192
test("fromConfig - expands exact tilde to home directory", () => {
132193
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
133194
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])

0 commit comments

Comments
 (0)