diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index f95aaf34525..4ec4d47e943 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -53,12 +53,19 @@ export namespace PermissionNext { return rulesets.flat() } + export const Command = z.object({ + head: z.string(), + tail: z.string().array(), + }) + export type Command = z.infer + export const Request = z .object({ id: Identifier.schema("permission"), sessionID: Identifier.schema("session"), permission: z.string(), - patterns: z.string().array(), + patterns: z.string().array().optional(), + commands: Command.array().optional(), metadata: z.record(z.string(), z.any()), always: z.string().array(), tool: z @@ -120,6 +127,38 @@ export namespace PermissionNext { async (input) => { const s = await state() const { ruleset, ...request } = input + + // Handle bash commands with structured matching + if (request.permission === "bash" && request.commands) { + const commands = request.commands // capture narrowed value before closure + for (const command of commands) { + const rule = evaluateBash(command, ruleset, s.approved) + log.info("evaluated bash", { command, 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, + // Include patterns for display in UI + patterns: commands.map((c) => [c.head, ...c.tail].join(" ")), + } + s.pending[id] = { + info, + resolve, + reject, + } + Bus.publish(Event.Asked, info) + }) + } + if (rule.action === "allow") continue + } + return + } + + // Handle standard patterns for other permission types for (const pattern of request.patterns ?? []) { const rule = evaluate(request.permission, pattern, ruleset, s.approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) @@ -196,9 +235,13 @@ export namespace PermissionNext { const sessionID = existing.info.sessionID for (const [id, pending] of Object.entries(s.pending)) { if (pending.info.sessionID !== sessionID) continue - const ok = pending.info.patterns.every( - (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow", - ) + // Check if all commands/patterns are now allowed + const ok = + pending.info.permission === "bash" && pending.info.commands + ? pending.info.commands.every((command) => evaluateBash(command, s.approved).action === "allow") + : (pending.info.patterns ?? []).every( + (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow", + ) if (!ok) continue delete s.pending[id] Bus.publish(Event.Replied, { @@ -226,6 +269,15 @@ export namespace PermissionNext { return match ?? { action: "ask", permission, pattern: "*" } } + export function evaluateBash(command: Command, ...rulesets: Ruleset[]): Rule { + const merged = merge(...rulesets) + const bashRules = merged.filter((rule) => Wildcard.match("bash", rule.permission)) + const patterns = Object.fromEntries(bashRules.map((rule) => [rule.pattern, rule.action])) + const action = Wildcard.allStructured(command, patterns) + if (action === undefined) return { action: "ask", permission: "bash", pattern: "*" } + return { action, permission: "bash", pattern: "*" } + } + const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] export function disabled(tools: string[], ruleset: Ruleset): Set { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f3a1b04d431..624faf14fe7 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -86,31 +86,27 @@ export const BashTool = Tool.define("bash", async () => { } const directories = new Set() if (!Instance.containsPath(cwd)) directories.add(cwd) - const patterns = new Set() + const commands: Array<{ head: string; tail: string[] }> = [] const always = new Set() for (const node of tree.rootNode.descendantsOfType("command")) { if (!node) continue - const command = [] + const tokens: string[] = [] for (let i = 0; i < node.childCount; i++) { const child = node.child(i) if (!child) continue - if ( - child.type !== "command_name" && - child.type !== "word" && - child.type !== "string" && - child.type !== "raw_string" && - child.type !== "concatenation" - ) { - continue - } - command.push(child.text) + // Include all meaningful text nodes (flags, args, commands, etc.) + if (child.text.trim()) tokens.push(child.text) } + if (tokens.length === 0) continue + + const head = tokens[0] + const tail = tokens.slice(1) // not an exhaustive list, but covers most common cases - if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(command[0])) { - for (const arg of command.slice(1)) { - if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue + if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"].includes(head)) { + for (const arg of tail) { + if (arg.startsWith("-") || (head === "chmod" && arg.startsWith("+"))) continue const resolved = await $`realpath ${arg}` .cwd(cwd) .quiet() @@ -130,9 +126,9 @@ export const BashTool = Tool.define("bash", async () => { } // cd covered by above check - if (command.length && command[0] !== "cd") { - patterns.add(command.join(" ")) - always.add(BashArity.prefix(command).join(" ") + "*") + if (head !== "cd") { + commands.push({ head, tail }) + always.add(BashArity.prefix(tokens).join(" ") + "*") } } @@ -145,10 +141,11 @@ export const BashTool = Tool.define("bash", async () => { }) } - if (patterns.size > 0) { + if (commands.length > 0) { await ctx.ask({ permission: "bash", - patterns: Array.from(patterns), + commands, + patterns: commands.map((c) => [c.head, ...c.tail].join(" ")), always: Array.from(always), metadata: {}, }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 1a08b15b4a8..7847025ecce 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -72,7 +72,7 @@ describe("tool.read external_directory permission", () => { await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns.some((p) => p.includes(outerTmp.path))).toBe(true) + expect(extDirReq!.patterns?.some((p) => p.includes(outerTmp.path))).toBe(true) }, }) }) @@ -147,7 +147,7 @@ describe("tool.read env file blocking", () => { const ctxWithPermissions = { ...ctx, ask: async (req: Omit) => { - for (const pattern of req.patterns) { + for (const pattern of req.patterns ?? []) { const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission) if (rule.action === "deny") { throw new PermissionNext.DeniedError(agent.permission)