feat(plugin): add permission review hooks (#45003)

Co-authored-by: R44VC0RP <R44VC0RP@users.noreply.github.com>
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-08-25 16:57:17 +00:00
committed by GitHub
parent c94a4913c0
commit 0fd719067d
22 changed files with 371 additions and 162 deletions
+2
View File
@@ -4,10 +4,12 @@ export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type GenerateApi = Client["generate"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
@@ -1417,6 +1417,7 @@ export type PermissionRequest = {
save?: Array<string>
metadata?: { [x: string]: JsonValue }
source?: PermissionSource
message?: string
}
export type PermissionAsked = {
@@ -1433,6 +1434,7 @@ export type PermissionAsked = {
save?: Array<string>
metadata?: { [x: string]: any }
source?: PermissionSource
message?: string
}
}
+49 -56
View File
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema.js"
import { SessionStore } from "./session/store.js"
import { Wildcard } from "./util/wildcard.js"
import { PermissionSaved } from "./permission/saved.js"
import { PluginHooks } from "./plugin/hooks.js"
const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
@@ -70,9 +71,10 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
rules: Permission.Ruleset,
permission: Schema.String,
resources: Schema.Array(Schema.String),
reason: Schema.String.pipe(Schema.optional),
}) {
override get message() {
return `Permission denied: ${this.permission}`
return this.reason ?? `Permission denied: ${this.permission}`
}
}
@@ -99,11 +101,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly allowsAll: (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -128,6 +125,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
@@ -159,24 +157,6 @@ const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) {
const rules = yield* configured(input.sessionID, input.agent)
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
for (let index = relevant.length - 1; index >= 0; index--) {
const rule = relevant[index]
if (rule.resource !== "*") {
if (rule.effect !== "allow") return false
continue
}
return rule.effect === "allow"
}
return false
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
@@ -191,10 +171,19 @@ const layer = Layer.effect(
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
agent: input.agent,
action: input.action,
resources: input.resources,
metadata: input.metadata,
source: input.source,
effect,
})
return { effect: event.effect, message: event.message, rules: all }
})
function request(input: AssertInput): Request {
function request(input: AssertInput, message?: string): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
@@ -203,6 +192,7 @@ const layer = Layer.effect(
save: input.save,
metadata: input.metadata,
source: input.source,
message,
}
}
@@ -223,39 +213,42 @@ const layer = Layer.effect(
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect }
})
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
),
Effect.gen(function* () {
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
reason: result.message,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
)
}),
)
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
@@ -337,12 +330,12 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node],
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
})
+4
View File
@@ -24,6 +24,8 @@ import { State } from "./state.js"
import { Tool } from "./tool.js"
import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
@@ -204,5 +206,7 @@ export const node = makeLocationNode({
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
Generate.node,
Permission.node,
],
})
+3
View File
@@ -5,6 +5,7 @@ import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
import type { PermissionHooks } from "@opencode-ai/plugin/effect/permission"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state.js"
@@ -12,6 +13,7 @@ import { State } from "../state.js"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly permission: PermissionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
}
@@ -22,6 +24,7 @@ type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
readonly permission: NoFailures<PermissionHooks>
readonly shell: NoFailures<ShellHooks>
readonly tool: ToolFailures
}
+32
View File
@@ -26,6 +26,8 @@ import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
@@ -46,6 +48,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const tools = yield* Tool.Service
const vcs = yield* Vcs.Service
const websearch = yield* WebSearch.Service
const generate = yield* Generate.Service
const permission = yield* Permission.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
const locationInfo = () =>
@@ -188,6 +192,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
event: {
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
},
generate: {
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
},
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
@@ -313,6 +320,30 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
})
}),
},
permission: {
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
get: (input) =>
permission
.get(input.requestID)
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? Effect.succeed(request)
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
reply: (input) =>
permission
.get(input.requestID)
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message })
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
},
plugin: {
list: () => response(plugin.list()),
},
@@ -417,6 +448,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.interrupt(input.sessionID, { continue: input.continue })
.pipe(Effect.map((interrupted) => ({ interrupted }))),
wait: (input) => runtime.session.wait(input.sessionID),
context: (input) => runtime.session.context(input.sessionID),
},
} satisfies Plugin.Context
})
+2
View File
@@ -26,6 +26,7 @@ export interface Interface {
| "interrupt"
| "synthetic"
| "wait"
| "context"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -81,6 +82,7 @@ export const layerWithCell = (cell: Cell) =>
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
+31 -45
View File
@@ -194,54 +194,40 @@ export const Plugin = {
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const unrestricted =
(yield* permission.allowsAll({
sessionID: context.sessionID,
action: name,
agent: context.agent,
})) &&
(yield* permission.allowsAll({
sessionID: context.sessionID,
action: "external_directory",
agent: context.agent,
}))
invocation.cwd = target.absolute
finalTimeout = invocation.timeout
if (!unrestricted) {
const portable =
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
const portable =
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter(
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter(
(item, index, items) =>
items.findIndex((other) => other.resource === item.resource) === index,
)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
+2 -5
View File
@@ -1,8 +1,5 @@
import { Permission } from "@opencode-ai/core/permission"
import { Effect, Layer } from "effect"
import { Layer } from "effect"
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
Layer.mock(Permission.Service, {
allowsAll: () => Effect.succeed(false),
...overrides,
})
Layer.mock(Permission.Service, overrides)
+79 -27
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -10,6 +10,7 @@ import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -26,7 +27,15 @@ const current = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[[Location.node, current]],
),
)
@@ -112,31 +121,6 @@ describe("Permission", () => {
}),
)
it.effect("proves only unconditional configured allows", () =>
Effect.gen(function* () {
const service = yield* Permission.Service
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
expect(yield* service.allowsAll(input)).toBe(true)
yield* setRules([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "rm *", effect: "deny" },
])
expect(yield* service.allowsAll(input)).toBe(false)
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
expect(yield* service.allowsAll(input)).toBe(false)
yield* setRules([
{ action: "shell", resource: "rm *", effect: "deny" },
{ action: "shell", resource: "*", effect: "allow" },
])
expect(yield* service.allowsAll(input)).toBe(true)
}),
)
it.effect("evaluates against an explicit provider-turn agent", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
@@ -172,6 +156,74 @@ describe("Permission", () => {
}),
)
it.effect("lets plugins review allow and ask decisions without overriding configured denies", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push(event.effect)
event.effect = event.action === "write" ? "deny" : "allow"
event.message = "Reviewed by policy"
}),
)
const service = yield* Permission.Service
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
yield* setRules([])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_ask") }))).toMatchObject({ effect: "allow" })
expect(yield* service.list()).toEqual([])
const blocked = yield* service
.assert(assertion({ id: Permission.ID.create("per_write"), action: "write" }))
.pipe(Effect.flip)
expect(blocked).toBeInstanceOf(Permission.BlockedError)
expect(blocked.message).toBe("Reviewed by policy")
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_deny") }))).toMatchObject({ effect: "deny" })
expect(seen).toEqual(["allow", "ask", "ask"])
}),
)
it.effect("publishes the reviewer message when a plugin escalates to ask", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
event.effect = "ask"
event.message = "Confirm production access"
}),
)
const service = yield* Permission.Service
const result = yield* service.ask(assertion())
expect(result.effect).toBe("ask")
expect(yield* service.get(result.id)).toMatchObject({ message: "Confirm production access" })
}),
)
it.effect("allows cancellation while a permission reviewer is running", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
const started = yield* Deferred.make<void>()
yield* hooks.register("permission", "evaluate", () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
)
const service = yield* Permission.Service
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
+20
View File
@@ -10,6 +10,7 @@ import { Bus } from "@opencode-ai/core/bus"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
@@ -18,6 +19,7 @@ import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
@@ -38,6 +40,20 @@ const npmLayer = Layer.succeed(
}),
)
const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text: () => Effect.succeed("") }))
const permissionLayer = Layer.succeed(
Permission.Service,
Permission.Service.of({
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
assert: () => Effect.void,
reply: () => Effect.void,
get: () => Effect.succeed(undefined),
forSession: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
)
export const PluginTestLayer = LayerNode.compile(
LayerNode.group([
FileSystem.node,
@@ -47,6 +63,7 @@ export const PluginTestLayer = LayerNode.compile(
Credential.node,
Bus.node,
Form.node,
Generate.node,
LayerNodePlatform.httpClient,
Plugin.node,
Agent.node,
@@ -57,6 +74,7 @@ export const PluginTestLayer = LayerNode.compile(
KV.node,
MCP.node,
PluginRuntime.node,
Permission.node,
PluginHooks.node,
Reference.node,
Skill.node,
@@ -72,5 +90,7 @@ export const PluginTestLayer = LayerNode.compile(
[Npm.node, npmLayer],
[Config.node, Config.testLayer()],
[MCP.node, emptyMcpLayer],
[Generate.node, generateLayer],
[Permission.node, permissionLayer],
],
) as unknown as Layer.Layer<unknown, never>
+10
View File
@@ -48,6 +48,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
generate: overrides.generate ?? {
text: () => Effect.die("unused generate.text"),
},
integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
@@ -81,6 +84,12 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
},
permission: overrides.permission ?? {
hook: () => Effect.die("unused permission.hook"),
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
reply: () => Effect.die("unused permission.reply"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
},
@@ -134,6 +143,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
context: overrides.session?.context ?? (() => Effect.die("unused session.context")),
},
}
}
-27
View File
@@ -46,12 +46,10 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const sessionID = Session.ID.make("ses_shell_tool_test")
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
const assertions: Permission.AssertInput[] = []
const allowedActions = new Set<string>()
let denyAction: string | undefined
let afterPermission = (_input: Permission.AssertInput): Effect.Effect<void> => Effect.void
const permission = permissionLayer({
allowsAll: (input) => Effect.succeed(allowedActions.has(input.action)),
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
@@ -71,7 +69,6 @@ const permission = permissionLayer({
const reset = () => {
assertions.length = 0
allowedActions.clear()
denyAction = undefined
afterPermission = () => Effect.void
}
@@ -365,30 +362,6 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
it.live(
"skips command decomposition when shell and external directories are unrestricted",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
allowedActions.add("shell")
allowedActions.add("external_directory")
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-unrestricted")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toEqual([])
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live(
"captures stderr-only and mixed stdout/stderr output",
() =>
+24
View File
@@ -0,0 +1,24 @@
import type { PermissionApi } from "@opencode-ai/client/effect/api"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Permission } from "@opencode-ai/schema/permission"
import type { Session } from "@opencode-ai/schema/session"
import type { Hooks } from "./registration.js"
export interface PermissionEvaluation {
readonly sessionID: Session.ID
readonly agent?: Agent.ID
readonly action: string
readonly resources: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly source?: Permission.Source
effect: Permission.Effect
message?: string
}
export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
+4 -1
View File
@@ -1,4 +1,4 @@
import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js"
import type { App } from "../app.js"
@@ -9,6 +9,7 @@ import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -28,6 +29,8 @@ export interface Context {
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly generate: GenerateApi<unknown>
readonly permission: PermissionDomain
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly session: SessionDomain
+1
View File
@@ -58,6 +58,7 @@ export type SessionDomain = Pick<
| "interrupt"
| "rename"
| "wait"
| "context"
> & {
readonly hook: ModelHooks<SessionHooks>
}
+13
View File
@@ -76,10 +76,12 @@ export function fromPromise(plugin: Plugin) {
)
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
const PermissionEndpoints = ClientApi.groups["server.permission"].endpoints
const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
@@ -174,6 +176,9 @@ export function fromPromise(plugin: Plugin) {
),
),
},
generate: {
text: adaptApiMethod(GenerateEndpoints["generate.text"], host.generate.text),
},
integration: {
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
@@ -261,6 +266,13 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.mcp),
reload: () => run(host.mcp.reload()),
},
permission: {
hook: (name, callback) =>
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
},
@@ -355,6 +367,7 @@ export function fromPromise(plugin: Plugin) {
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
},
shell: {
hook: (name, callback) =>
+24
View File
@@ -0,0 +1,24 @@
import type { PermissionApi } from "@opencode-ai/client/promise/api"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Permission } from "@opencode-ai/schema/permission"
import type { Session } from "@opencode-ai/schema/session"
import type { Hooks } from "./registration.js"
export interface PermissionEvaluation {
readonly sessionID: Session.ID
readonly agent?: Agent.ID
readonly action: string
readonly resources: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly source?: Permission.Source
effect: Permission.Effect
message?: string
}
export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
+4 -1
View File
@@ -1,4 +1,4 @@
import type { PluginApi } from "@opencode-ai/client/promise/api"
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
import type { PluginOptions } from "../options.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
@@ -8,6 +8,7 @@ import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { PermissionDomain } from "./permission.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -27,6 +28,8 @@ export interface Context {
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly generate: GenerateApi
readonly permission: PermissionDomain
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly session: SessionDomain
+1
View File
@@ -58,6 +58,7 @@ export type SessionDomain = Pick<
| "interrupt"
| "rename"
| "wait"
| "context"
> & {
readonly hook: ModelHooks<SessionHooks>
}
+1
View File
@@ -29,6 +29,7 @@ const RequestFields = {
save: Schema.Array(Schema.String).pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
source: Source.pipe(optional),
message: Schema.String.pipe(optional),
}
export const Request = Schema.Struct({
@@ -613,6 +613,27 @@ interface ReferenceDraft {
}
```
### Generate
Generate text with a selected model without creating a session, invoking tools, or adding to session history.
```ts
const review = await ctx.generate.text({
model: { providerID: "anthropic", id: "claude-sonnet-4-6" },
prompt: "Review this proposed action.",
})
```
### Permissions
Inspect or resolve pending permission requests.
```ts
const pending = await ctx.permission.list({ sessionID })
const request = await ctx.permission.get({ sessionID, requestID })
await ctx.permission.reply({ sessionID, requestID, reply: "once" })
```
### Sessions
Create or read a session.
@@ -620,6 +641,7 @@ Create or read a session.
```ts
const created = await ctx.session.create({ title: "Review" })
const session = await ctx.session.get({ sessionID })
const messages = await ctx.session.context({ sessionID })
```
Change the agent or model used by subsequent requests.
@@ -655,6 +677,7 @@ Schemas: [`Session.Info`](/api#schema-Session.Info), [`Model.Ref`](/api#schema-M
interface SessionContext {
create(input?: SessionCreateInput, requestOptions?: RequestOptions): Promise<SessionInfo>
get(input: SessionGetInput, requestOptions?: RequestOptions): Promise<SessionInfo>
context(input: SessionContextInput, requestOptions?: RequestOptions): Promise<readonly SessionMessageInfo[]>
switchAgent(input: SessionSwitchAgentInput, requestOptions?: RequestOptions): Promise<void>
switchModel(input: SessionSwitchModelInput, requestOptions?: RequestOptions): Promise<void>
prompt(input: SessionPromptInput, requestOptions?: RequestOptions): Promise<SessionInboxUser>
@@ -995,6 +1018,46 @@ interface SessionHookContext {
}
```
### Permissions
Review permission decisions after configured rules are evaluated and before an action runs or a permission prompt is
published. Hooks run for `allow` and `ask` decisions. An explicit configured `deny` is final and does not invoke the
hook.
```ts
await ctx.permission.hook("evaluate", async (event) => {
if (event.action === "read") return
const messages = await ctx.session.context({ sessionID: event.sessionID })
const review = await ctx.generate.text({
model: { providerID: "anthropic", id: "claude-sonnet-4-6" },
prompt: buildSafetyPrompt({ messages, action: event.action, resources: event.resources }),
})
const decision = parseDecision(review.text)
event.effect = decision.effect
event.message = decision.reason
})
```
The hook may set `effect` to `allow`, `ask`, or `deny`. When set, `message` is included in an escalated permission
request or used as the denial reason.
#### Reference
```ts
interface PermissionEvaluation {
readonly sessionID: string
readonly agent?: string
readonly action: string
readonly resources: readonly string[]
readonly metadata?: Record<string, unknown>
readonly source?: { type: "tool"; messageID: string; id: string }
effect: "allow" | "ask" | "deny"
message?: string
}
```
### Shell
Modify shell commands, working directories, timeouts, executables, or environment variables before execution.