Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/opencode/src/permission/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down