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
23 changes: 20 additions & 3 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission"
import { InstanceState } from "@/effect/instance-state"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
import { Deferred, Effect, Layer, Context } from "effect"
import { Cause, Deferred, Effect, Layer, Context } from "effect"
import os from "os"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Plugin } from "@/plugin"

export const Event = PermissionV1.Event

Expand Down Expand Up @@ -43,6 +44,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const plugin = yield* Plugin.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
void ctx
Expand Down Expand Up @@ -93,9 +95,24 @@ const layer = Layer.effect(
always: request.always,
tool: request.tool,
}
yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns })
const output: { status: "ask" | "allow" | "deny" } = { status: "ask" }
yield* Effect.sync(() => structuredClone(info)).pipe(
Effect.flatMap((input) => plugin.trigger("permission.ask", input, output)),
Effect.catchCause((cause) => {
if (Cause.hasInterrupts(cause)) return Effect.failCause(cause)
output.status = "ask"
return Effect.logError("permission ask plugin failed", { cause })
}),
)
if (output.status === "allow") return
if (output.status === "deny") {
return yield* new PermissionV1.DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}

const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>()
yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns })
pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
Expand Down Expand Up @@ -218,6 +235,6 @@ export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
}

export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Plugin.node] })

export * as Permission from "."
213 changes: 211 additions & 2 deletions packages/opencode/test/permission/next.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { test, expect } from "bun:test"
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import * as TestConsole from "effect/testing/TestConsole"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Npm } from "@opencode-ai/core/npm"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { MessageID, SessionID } from "../../src/session/schema"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"

const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const env = AppNodeBuilder.build(
LayerNode.group([Permission.node, EventV2Bridge.node, CrossSpawnSpawner.node, InstanceStore.node]),
[[InstanceStore.bootstrapNode, noopBootstrap]],
[
[InstanceStore.bootstrapNode, noopBootstrap],
[Auth.node, AuthTest.empty],
[Account.node, AccountTest.empty],
[Npm.node, NpmTest.noop],
[RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })],
],
)
const it = testEffect(env)

Expand All @@ -43,7 +60,7 @@ const waitForPending = (count: number) =>
}
}).pipe(
Effect.timeoutOrElse({
duration: "1 second",
duration: "10 seconds",
orElse: () => Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`)),
}),
)
Expand Down Expand Up @@ -74,6 +91,27 @@ const list = () =>
return yield* permission.list()
})

const permissionHook = (body: string) =>
["export default async () => ({", ' "permission.ask": async (input, output) => {', body, " },", "})", ""].join("\n")

const withPlugins = (...sources: string[]) => ({
git: true,
init: (directory: string) =>
Effect.promise(async () => {
const plugins = await Promise.all(
sources.map(async (source, index) => {
const file = path.join(directory, `plugin-${index}.ts`)
await Bun.write(file, source)
return pathToFileURL(file).href
}),
)
await Bun.write(
path.join(directory, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: plugins }),
)
}),
})

// fromConfig tests

test("fromConfig - string value becomes wildcard rule", () => {
Expand Down Expand Up @@ -694,6 +732,177 @@ it.instance(
{ git: true },
)

it.instance(
"ask - fully static allow bypasses permission hook",
() =>
Effect.gen(function* () {
yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
})
expect(yield* list()).toHaveLength(0)
}),
withPlugins(permissionHook(' throw new Error("hook should not run")')),
)

it.instance(
"ask - static deny cannot be overridden by permission hook",
() =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
)
expect(err).toBeInstanceOf(PermissionV1.DeniedError)
}),
withPlugins(permissionHook(' output.status = "allow"')),
)

it.instance(
"ask - permission hook deny returns DeniedError",
() =>
Effect.gen(function* () {
const err = yield* fail(
ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}),
)
expect(err).toBeInstanceOf(PermissionV1.DeniedError)
expect(yield* list()).toHaveLength(0)
expect(JSON.stringify(yield* TestConsole.logLines)).not.toContain("asking")
}),
withPlugins(permissionHook(' output.status = "deny"')),
)

it.instance(
"ask - later permission hook overrides earlier decision",
() =>
Effect.gen(function* () {
yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
expect(yield* list()).toHaveLength(0)
expect(JSON.stringify(yield* TestConsole.logLines)).not.toContain("asking")
}),
withPlugins(permissionHook(' output.status = "deny"'), permissionHook(' output.status = "allow"')),
)

it.instance(
"ask - later permission hook failure falls back to ask and logs",
() =>
Effect.gen(function* () {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)

expect(yield* waitForPending(1)).toHaveLength(1)
const logs = JSON.stringify(yield* TestConsole.logLines)
expect(logs).toContain("permission ask plugin failed")
expect(logs).toContain("asking")
yield* rejectAll()
yield* Fiber.await(fiber)
}),
withPlugins(
permissionHook(' output.status = "allow"'),
permissionHook(' throw new Error("later hook failed")'),
),
)

it.instance(
"ask - permission hook cannot mutate nested pending request metadata",
() =>
Effect.gen(function* () {
const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { nested: { value: "original" } },
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)

const pending = yield* waitForPending(1)
expect(pending[0]?.metadata).toEqual({ nested: { value: "original" } })
yield* rejectAll()
yield* Fiber.await(fiber)
}),
withPlugins(permissionHook(' input.metadata.nested.value = "mutated"')),
)

it.instance(
"ask - interruption during permission hook leaves no request or asked event",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const marker = path.join(test.directory, "hook-started")
let asked = 0
const events = yield* EventV2Bridge.Service
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === Permission.Event.Asked.type) asked++
}),
)
yield* Effect.addFinalizer(() => unsubscribe)

const fiber = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
}).pipe(Effect.forkScoped)

yield* Effect.gen(function* () {
while (!(yield* Effect.promise(() => Bun.file(marker).exists()))) yield* Effect.sleep("10 millis")
}).pipe(
Effect.timeoutOrElse({
duration: "1 second",
orElse: () => Effect.fail(new Error("timed out waiting for permission hook")),
}),
)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)

expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true)
expect(yield* list()).toHaveLength(0)
expect(asked).toBe(0)
}),
withPlugins(
permissionHook(
[
' await Bun.write(new URL("hook-started", import.meta.url), "started")',
" return new Promise(() => {})",
].join("\n"),
),
),
)

// reply tests

it.instance(
Expand Down
5 changes: 2 additions & 3 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import type {
Project,
Model,
Provider,
Permission,
UserMessage,
Message,
Part,
Config as SDKConfig,
} from "@opencode-ai/sdk"
import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2"
import type { Provider as ProviderV2, Model as ModelV2, Auth, PermissionRequest } from "@opencode-ai/sdk/v2"

import type { BunShell } from "./shell.js"
import { type ToolDefinition } from "./tool.js"
Expand Down Expand Up @@ -258,7 +257,7 @@ 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" }) => Promise<void>
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
Expand Down
Loading