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
39 changes: 38 additions & 1 deletion packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -192,8 +207,30 @@ export namespace Permission {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
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)
}),
Expand Down
93 changes: 93 additions & 0 deletions packages/opencode/test/permission/next.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(() => {})
},
})
})
Loading