From e4ef65c04be445b82e072fb072d9b18a28e8d3df Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Tue, 17 Feb 2026 21:04:44 -0800 Subject: [PATCH] fix: root-level permission config not being respected In fromConfig, wildcard permission keys like "*" could end up after specific tool keys (like "bash") due to key reordering from mergeDeep during config merging. Since evaluate uses findLast, the catch-all "*" rule would win over the more specific tool rule. Fix by sorting wildcard permission keys before specific keys in fromConfig so specific tool rules always appear later and take precedence. Closes #8832 --- packages/opencode/src/permission/next.ts | 10 +++++++++- packages/opencode/test/agent/agent.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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: {