diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce9..f0814a3c321e 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -45,7 +45,15 @@ export namespace PermissionNext { export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] - for (const [key, value] of Object.entries(permission)) { + // Process wildcard permission keys first so that specific tool rules + // (like "bash") always appear later and win via findLast. + // This prevents key ordering issues from mergeDeep where "*" can end up + // after specific tools in the object. + // See: https://github.com/anomalyco/opencode/issues/8832 + const entries = Object.entries(permission) + const wildcards = entries.filter(([key]) => key.includes("*")) + const rest = entries.filter(([key]) => !key.includes("*")) + for (const [key, value] of [...wildcards, ...rest]) { if (typeof value === "string") { ruleset.push({ permission: key, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 9f8de04f80c4..0f1b10cf36d4 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -652,6 +652,19 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn }) }) +test("root-level permission config is respected", () => { + // https://github.com/anomalyco/opencode/issues/8832 + // User has { "permission": { "*": "ask", "bash": { "*": "ask", "ls *": "allow" } } } + // After config merging, key order may put "*" after "bash", so { *, *, ask } + // ends up after { bash, ls *, allow } in the ruleset. The specific bash rule + // should still win over the catch-all "*" rule. + const ruleset = PermissionNext.fromConfig({ + bash: { "*": "ask", "ls *": "allow" }, + "*": "ask", + }) + expect(PermissionNext.evaluate("bash", "ls -la", ruleset).action).toBe("allow") +}) + test("defaultAgent returns plan when build is disabled and default_agent not set", async () => { await using tmp = await tmpdir({ config: {