Skip to content

Commit 66f9303

Browse files
authored
fix permission config order (#24222)
1 parent 9ff999c commit 66f9303

4 files changed

Lines changed: 40 additions & 70 deletions

File tree

packages/opencode/src/config/permission.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * as ConfigPermission from "./permission"
22
import { Schema, SchemaGetter } from "effect"
3-
import { zod } from "@/util/effect-zod"
3+
import z from "zod"
4+
import { ZodOverride, zod } from "@/util/effect-zod"
45
import { withStatics } from "@/util/schema"
56

67
export const Action = Schema.Literals(["ask", "allow", "deny"])
@@ -18,17 +19,9 @@ export const Rule = Schema.Union([Action, Object])
1819
.pipe(withStatics((s) => ({ zod: zod(s) })))
1920
export type Rule = Schema.Schema.Type<typeof Rule>
2021

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.
22+
// Known permission keys get explicit types in the Effect schema for generated
23+
// docs/types. Runtime config parsing uses `InfoZod` below so user key order is
24+
// preserved for permission precedence.
3225
const InputObject = Schema.StructWithRest(
3326
Schema.Struct({
3427
read: Schema.optional(Rule),
@@ -60,6 +53,18 @@ const InputSchema = Schema.Union([Action, InputObject])
6053
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
6154
typeof input === "string" ? { "*": input } : input
6255

56+
const ACTION_ONLY = new Set(["todowrite", "question", "webfetch", "websearch", "codesearch", "doom_loop"])
57+
58+
const InfoZod = z
59+
.union([zod(Action), z.record(z.string(), z.union([zod(Action), z.record(z.string(), zod(Action))]))])
60+
.transform(normalizeInput)
61+
.superRefine((input, ctx) => {
62+
for (const [key, value] of globalThis.Object.entries(input)) {
63+
if (!ACTION_ONLY.has(key) || typeof value === "string") continue
64+
ctx.addIssue({ code: "custom", message: `${key} must be a permission action`, path: [key] })
65+
}
66+
})
67+
6368
export const Info = InputSchema.pipe(
6469
Schema.decodeTo(InputObject, {
6570
decode: SchemaGetter.transform(normalizeInput),
@@ -70,6 +75,7 @@ export const Info = InputSchema.pipe(
7075
}),
7176
)
7277
.annotate({ identifier: "PermissionConfig" })
78+
.annotate({ [ZodOverride]: InfoZod })
7379
.pipe(
7480
// Walker already emits the decodeTo transform into the derived zod (see
7581
// `encoded()` in effect-zod.ts), so just expose that directly.

packages/opencode/src/permission/index.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -288,18 +288,8 @@ function expand(pattern: string): string {
288288
}
289289

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

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

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

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.
1498+
test("permission config preserves user key order", async () => {
1499+
// Permission precedence follows the order users write in config, so parsing
1500+
// must not canonicalise known keys ahead of wildcard or custom keys.
15081501
await using tmp = await tmpdir({
15091502
init: async (dir) => {
15101503
await Filesystem.write(
@@ -1532,15 +1525,12 @@ test("permission config canonicalises known keys first, preserves rest-key inser
15321525
fn: async () => {
15331526
const config = await load()
15341527
expect(Object.keys(config.permission!)).toEqual([
1535-
// known fields that the user provided, in declaration order from
1536-
// config/permission.ts (read, edit, ..., external_directory, todowrite)
1537-
"read",
1528+
"*",
15381529
"edit",
1530+
"write",
15391531
"external_directory",
1532+
"read",
15401533
"todowrite",
1541-
// rest keys (not in the known list), in user's insertion order
1542-
"*",
1543-
"write",
15441534
"thoughts_*",
15451535
"reasoning_model_*",
15461536
"tools_*",

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

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -128,61 +128,45 @@ 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", () => {
131+
// Permission precedence follows config insertion order. `evaluate()` uses the
132+
// last matching rule, so later config entries intentionally override earlier
133+
// entries even when a wildcard appears after a specific permission.
134+
135+
test("fromConfig - preserves top-level config key order", () => {
142136
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
143137
const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
144138

145-
// Both orderings produce the same ruleset
146-
expect(wildcardFirst).toEqual(specificFirst)
139+
expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "bash"])
140+
expect(specificFirst.map((r) => r.permission)).toEqual(["bash", "*"])
147141

148-
// And both evaluate bash → allow (bash rule wins over * fallback)
149142
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
150-
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
143+
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("deny")
151144
})
152145

153-
test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => {
154-
const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" })
146+
test("fromConfig - wildcard acts as fallback when it appears before specifics", () => {
147+
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow" })
155148
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
156149
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
157150
})
158151

159-
test("fromConfig - top-level ordering: wildcards first, specifics after", () => {
152+
test("fromConfig - top-level ordering is not sorted by wildcard specificity", () => {
160153
const ruleset = Permission.fromConfig({
161154
bash: "allow",
162155
"*": "ask",
163156
edit: "deny",
164157
"mcp_*": "allow",
165158
})
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"])
159+
expect(ruleset.map((r) => r.permission)).toEqual(["bash", "*", "edit", "mcp_*"])
170160
})
171161

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.
162+
test("fromConfig - sub-pattern insertion order inside a tool key is preserved", () => {
176163
const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
177164
expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
178-
// * fallback for unknown commands
179165
expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
180-
// specific pattern wins for git commands (it's last, findLast picks it)
181166
expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
182167
})
183168

184-
test("fromConfig - canonical documented example unchanged", () => {
185-
// Regression guard for the example in docs/permissions.mdx
169+
test("fromConfig - documented fallback-first example", () => {
186170
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
187171
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
188172
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
@@ -448,7 +432,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
448432
expect(result.action).toBe("ask")
449433
})
450434

451-
test("evaluate - permission patterns sorted by length regardless of object order", () => {
435+
test("evaluate - later wildcard permission can override earlier specific permission", () => {
452436
const result = Permission.evaluate("bash", "rm", [
453437
{ permission: "bash", pattern: "*", action: "allow" },
454438
{ permission: "*", pattern: "*", action: "deny" },

0 commit comments

Comments
 (0)