Skip to content

Instantly share code, notes, and snippets.

@jbking
Created May 9, 2026 05:54
Show Gist options
  • Select an option

  • Save jbking/5805ae7ad4adf9bde22c70aabe80eed9 to your computer and use it in GitHub Desktop.

Select an option

Save jbking/5805ae7ad4adf9bde22c70aabe80eed9 to your computer and use it in GitHub Desktop.
opencode PR #19453 (permission.ask hook) applied to v1.14.41 + Layer.suspend + catchCause fix
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index d93670709..95bc99a8f 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -7,6 +7,7 @@ import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
+import { Plugin } from "@/plugin"
import { zod } from "@/util/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
import { withStatics } from "@/util/schema"
@@ -153,6 +154,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
+ const plugin = yield* Plugin.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = Database.use((db) =>
@@ -202,6 +204,22 @@ export const layer = Layer.effect(
})
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
+ type PermissionAskOutput = { status: "ask" | "deny" | "allow"; message?: string }
+ const hookOutput: PermissionAskOutput = { status: "ask", message: undefined }
+ const hook = yield* plugin
+ .trigger("permission.ask", info as any, hookOutput)
+ .pipe(
+ Effect.catchCause((cause) => {
+ log.warn("permission.ask hook failed", { cause })
+ return Effect.succeed<PermissionAskOutput>({ status: "ask", message: undefined })
+ }),
+ ) as Effect.Effect<PermissionAskOutput>
+ if (hook.status === "deny") {
+ if (hook.message) return yield* new CorrectedError({ feedback: hook.message })
+ return yield* new RejectedError()
+ }
+ if (hook.status === "allow") return
+
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
@@ -319,6 +337,9 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return result
}
-export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+export const defaultLayer = layer.pipe(
+ Layer.provide(Bus.layer),
+ Layer.provide(Layer.suspend(() => Plugin.defaultLayer)),
+)
export * as Permission from "."
diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts
index d3ca54268..7fb86b134 100644
--- a/packages/opencode/src/tool/shell.ts
+++ b/packages/opencode/src/tool/shell.ts
@@ -264,7 +264,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
return tree
})
-const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
+const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, command?: string) {
if (scan.dirs.size > 0) {
const globs = Array.from(scan.dirs).map((dir) => {
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
@@ -283,7 +283,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan)
permission: ShellID.ToolID,
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
- metadata: {},
+ metadata: command ? { command } : {},
})
})
@@ -609,7 +609,7 @@ export const ShellTool = Tool.define(
)
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
- yield* ask(ctx, scan)
+ yield* ask(ctx, scan, params.command)
}),
)
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 1c3d6fc56..0d50137e4 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -1,8 +1,11 @@
import { afterEach, test, expect } from "bun:test"
import os from "os"
+import path from "path"
+import { pathToFileURL } from "url"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Bus } from "../../src/bus"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { Plugin } from "../../src/plugin"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
@@ -19,7 +22,11 @@ import { testEffect } from "../lib/effect"
import { MessageID, SessionID } from "../../src/session/schema"
const bus = Bus.layer
-const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer)
+const env = Layer.mergeAll(
+ Permission.layer.pipe(Layer.provide(bus), Layer.provide(Plugin.defaultLayer)),
+ bus,
+ CrossSpawnSpawner.defaultLayer,
+)
const it = testEffect(env)
afterEach(async () => {
@@ -82,6 +89,23 @@ function withProvided(dir: string) {
return <A, E, R>(self: Effect.Effect<A, E, R>) => self.pipe(provideInstance(dir))
}
+const writePermissionHookPlugin = (dir: string, source: string) =>
+ Effect.promise(async () => {
+ const file = path.join(dir, "permission-hook-plugin.ts")
+ await Bun.write(file, source)
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify(
+ {
+ $schema: "https://opencode.ai/config.json",
+ plugin: [pathToFileURL(file).href],
+ },
+ null,
+ 2,
+ ),
+ )
+ })
+
// fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => {
@@ -1132,3 +1156,94 @@ it.live("ask - abort should clear pending request", () =>
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
}),
)
+
+it.live("ask - permission.ask hook allow bypasses prompt", () =>
+ withDir({ git: true }, (dir) =>
+ Effect.gen(function* () {
+ yield* writePermissionHookPlugin(
+ dir,
+ `export default async () => ({ "permission.ask": async (_input, output) => { output.status = "allow" } })`,
+ )
+ const result = yield* ask({
+ sessionID: SessionID.make("session_hook_allow"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+ expect(result).toBeUndefined()
+ expect(yield* list()).toHaveLength(0)
+ }),
+ ),
+)
+
+it.live("ask - permission.ask hook deny rejects without prompt", () =>
+ withDir({ git: true }, (dir) =>
+ Effect.gen(function* () {
+ yield* writePermissionHookPlugin(
+ dir,
+ `export default async () => ({ "permission.ask": async (_input, output) => { output.status = "deny" } })`,
+ )
+ const err = yield* fail(
+ ask({
+ sessionID: SessionID.make("session_hook_deny"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }),
+ )
+ expect(err).toBeInstanceOf(Permission.RejectedError)
+ expect(yield* list()).toHaveLength(0)
+ }),
+ ),
+)
+
+it.live("ask - permission.ask hook deny with message returns corrected error", () =>
+ withDir({ git: true }, (dir) =>
+ Effect.gen(function* () {
+ yield* writePermissionHookPlugin(
+ dir,
+ `export default async () => ({ "permission.ask": async (_input, output) => { output.status = "deny"; output.message = "blocked by policy" } })`,
+ )
+ const err = yield* fail(
+ ask({
+ sessionID: SessionID.make("session_hook_deny_msg"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }),
+ )
+ expect(err).toBeInstanceOf(Permission.CorrectedError)
+ expect(String(err)).toContain("blocked by policy")
+ expect(yield* list()).toHaveLength(0)
+ }),
+ ),
+)
+
+it.live("ask - permission.ask hook failure falls back to prompt", () =>
+ withDir({ git: true }, (dir) =>
+ Effect.gen(function* () {
+ yield* writePermissionHookPlugin(
+ dir,
+ `export default async () => ({ "permission.ask": async () => { throw new Error("plugin boom") } })`,
+ )
+ const fiber = yield* ask({
+ sessionID: SessionID.make("session_hook_error"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }).pipe(Effect.forkScoped)
+
+ expect(yield* waitForPending(1)).toHaveLength(1)
+ yield* rejectAll()
+ yield* Fiber.await(fiber)
+ }),
+ ),
+)
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index 2e96dd980..d7638e9b9 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -4,14 +4,13 @@ import type {
Project,
Model,
Provider,
- Permission,
UserMessage,
Message,
Part,
Auth,
Config as SDKConfig,
} from "@opencode-ai/sdk"
-import type { Provider as ProviderV2, Model as ModelV2 } from "@opencode-ai/sdk/v2"
+import type { Provider as ProviderV2, Model as ModelV2, PermissionRequest } from "@opencode-ai/sdk/v2"
import type { BunShell } from "./shell.js"
import { type ToolDefinition } from "./tool.js"
@@ -257,7 +256,10 @@ export interface Hooks {
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
output: { headers: Record<string, string> },
) => Promise<void>
- "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
+ "permission.ask"?: (
+ input: PermissionRequest,
+ output: { status: "ask" | "deny" | "allow"; message?: string },
+ ) => Promise<void>
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment