diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1a7bd2c610a5..c2f3d10d43c4 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -11,6 +11,7 @@ import { Database, eq } from "@/storage/db" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import type { Permission as PluginPermission } from "@opencode-ai/sdk" import os from "os" import z from "zod" import { evaluate as evalRule } from "./evaluate" @@ -130,6 +131,20 @@ export namespace Permission { approved: Ruleset } + function toPluginPermission(info: Request): PluginPermission { + return { + id: String(info.id), + type: info.permission, + pattern: info.patterns.length <= 1 ? info.patterns[0] : info.patterns, + sessionID: String(info.sessionID), + messageID: info.tool?.messageID ? String(info.tool.messageID) : MessageID.make("message_permission_hook"), + callID: info.tool?.callID, + title: info.permission, + metadata: info.metadata, + time: { created: Date.now() }, + } + } + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) return evalRule(permission, pattern, ...rulesets) @@ -192,8 +207,30 @@ export namespace Permission { const deferred = yield* Deferred.make() pending.set(id, { info, deferred }) void Bus.publish(Event.Asked, info) + return yield* Effect.ensuring( - Deferred.await(deferred), + Effect.gen(function* () { + const pluginOutput = { status: "ask" as "ask" | "allow" | "deny" } + yield* Effect.tryPromise(() => + import("../plugin/index").then(({ Plugin }) => + Plugin.trigger("permission.ask", toPluginPermission(info), pluginOutput), + ), + ).pipe( + Effect.catch((error) => { + log.warn("permission.ask plugin hook failed", { error }) + return Effect.void + }), + ) + + if (pluginOutput.status === "allow") return + if (pluginOutput.status === "deny") { + return yield* new DeniedError({ + ruleset: input.ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + }) + } + + return yield* Deferred.await(deferred) + }), Effect.sync(() => { pending.delete(id) }), diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b64f..365a377dcdce 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,5 +1,6 @@ import { afterEach, test, expect } from "bun:test" import os from "os" +import path from "path" import { Bus } from "../../src/bus" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" @@ -1146,3 +1147,95 @@ test("ask - abort should clear pending request", async () => { }, }) }) + +// plugin permission.ask hook tests + +async function writePermissionPlugin(dir: string, mode: "allow" | "deny" | "throw") { + const code = ` + export default async function plugin() { + return { + "permission.ask": async (_input, output) => { + ${mode === "throw" ? 'throw new Error("plugin boom")' : `output.status = "${mode}"`} + }, + } + } + ` + const pluginPath = path.join(dir, "test-plugin.mjs") + await Bun.write(pluginPath, code) + return pluginPath +} + +test("ask - plugin permission.ask can allow without prompting", async () => { + await using tmp = await tmpdir({ git: true }) + const pluginPath = await writePermissionPlugin(tmp.path, "allow") + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: [`file://${pluginPath}`] }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await Permission.ask({ + sessionID: SessionID.make("session_plugin_allow"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + expect(result).toBeUndefined() + expect(await Permission.list()).toHaveLength(0) + }, + }) +}) + +test("ask - plugin permission.ask can deny before prompting", async () => { + await using tmp = await tmpdir({ git: true }) + const pluginPath = await writePermissionPlugin(tmp.path, "deny") + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: [`file://${pluginPath}`] }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect( + Permission.ask({ + sessionID: SessionID.make("session_plugin_deny"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + ).rejects.toBeInstanceOf(Permission.DeniedError) + expect(await Permission.list()).toHaveLength(0) + }, + }) +}) + +test("ask - plugin permission.ask errors fall back to prompt flow", async () => { + await using tmp = await tmpdir({ git: true }) + const pluginPath = await writePermissionPlugin(tmp.path, "throw") + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: [`file://${pluginPath}`] }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = Permission.ask({ + sessionID: SessionID.make("session_plugin_throw"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + const pending = await waitForPending(1) + expect(pending).toHaveLength(1) + await rejectAll() + await ask.catch(() => {}) + }, + }) +})