diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce9..3d8fca69fe8b 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -9,6 +9,7 @@ import { fn } from "@/util/fn" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import os from "os" +import path from "path" import z from "zod" export namespace PermissionNext { @@ -135,28 +136,37 @@ export namespace PermissionNext { async (input) => { const s = await state() const { ruleset, ...request } = input - for (const pattern of request.patterns ?? []) { - const rule = evaluate(request.permission, pattern, ruleset, s.approved) - log.info("evaluated", { permission: request.permission, pattern, action: rule }) - if (rule.action === "deny") - throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) - if (rule.action === "ask") { - const id = input.id ?? Identifier.ascending("permission") - return new Promise((resolve, reject) => { - const info: Request = { - id, - ...request, - } - s.pending[id] = { - info, - resolve, - reject, - } - Bus.publish(Event.Asked, info) - }) + const patterns = request.patterns ?? [] + // evaluate each pattern as both relative and absolute, keep the most specific match + const results = patterns.flatMap((pattern) => { + const candidates = [pattern] + if (!path.isAbsolute(pattern)) candidates.push(path.resolve(Instance.worktree, pattern)) + return candidates.map((p) => { + const rule = evaluate(request.permission, p, ruleset, s.approved) + log.info("evaluated", { permission: request.permission, pattern: p, action: rule }) + return rule + }) + }) + + const best = results.reduce((a, b) => (b.pattern.length > a.pattern.length ? b : a), results[0]) + if (!best || best.action === "allow") return + + if (best.action === "deny") + throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) + + const id = input.id ?? Identifier.ascending("permission") + return new Promise((resolve, reject) => { + const info: Request = { + id, + ...request, } - if (rule.action === "allow") continue - } + s.pending[id] = { + info, + resolve, + reject, + } + Bus.publish(Event.Asked, info) + }) }, ) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index add3332048c8..76d015424b59 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -474,6 +474,92 @@ test("ask - resolves immediately when action is allow", async () => { }) }) +test("ask - absolute specific allow beats wildcard deny for relative edit path", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await PermissionNext.ask({ + sessionID: "session_test", + permission: "edit", + patterns: ["src/foo.ts"], + metadata: {}, + always: [], + ruleset: [ + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "edit", pattern: `${tmp.path}/src/**`, action: "allow" }, + ], + }) + expect(result).toBeUndefined() + }, + }) +}) + +test("ask - absolute specific deny beats wildcard allow for relative edit path", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect( + PermissionNext.ask({ + sessionID: "session_test", + permission: "edit", + patterns: ["src/secret.ts"], + metadata: {}, + always: [], + ruleset: [ + { permission: "edit", pattern: "*", action: "allow" }, + { permission: "edit", pattern: `${tmp.path}/src/**`, action: "deny" }, + ], + }), + ).rejects.toBeInstanceOf(PermissionNext.DeniedError) + }, + }) +}) + +test("ask - absolute specific allow beats wildcard deny for bash command", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["npm test"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "npm *", action: "allow" }, + ], + }) + expect(result).toBeUndefined() + }, + }) +}) + +test("ask - specific deny beats wildcard allow for bash command", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect( + PermissionNext.ask({ + sessionID: "session_test", + permission: "bash", + patterns: ["rm -rf /"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }), + ).rejects.toBeInstanceOf(PermissionNext.DeniedError) + }, + }) +}) + test("ask - throws RejectedError when action is deny", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({