Created
May 9, 2026 05:54
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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